Build

Queries

Rows live at /v1/apps/{app}/tables/{table}/rows. Filters follow the PostgREST style, so done=eq.false means "where done is false".

Example table

This page uses one notes table:

berth tables create --app "$APP" notes title:text:notnull slug:text:unique done:boolean priority:integer 'tags:text[]'

Bulk insert

Send an array to insert up to 1,000 rows in one request. It is one transaction: all rows go in or none do.

curl -sS -X POST https://api.atberth.com/v1/apps/$APP/tables/notes/rows \
  -H "Authorization: Bearer $SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '[
    {"title":"Buy milk","slug":"milk","done":false,"priority":2,"tags":["home","shop"]},
    {"title":"Buy bread","slug":"bread","done":true,"priority":1,"tags":["shop"]},
    {"title":"Fix the sink","slug":"sink","done":false,"priority":3,"tags":["home"]},
    {"title":"Read a book","slug":"book","done":false},
    {"title":"File taxes","slug":"taxes","done":false,"priority":3}
  ]' | jq '.rows | length'

A single object also works and returns "row" as well as "rows":

ROW_ID=$(curl -sS -X POST https://api.atberth.com/v1/apps/$APP/tables/notes/rows \
  -H "Authorization: Bearer $SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"title":"Walk the dog","slug":"dog","priority":1}' | jq -er .row.id)

Filters

Each filter is column=op.value. Several filters are ANDed. Using curl -G --data-urlencode keeps characters like *, (, and [ safe.

OpMeaningExample
eq, neqequal, not equaldone=eq.false
gt, gte, lt, ltecomparisonspriority=gte.2
like, ilikepattern, * is the wildcard; ilike ignores casetitle=ilike.*milk*
inone of a listslug=in.(milk,bread)
isnull, true, falsepriority=is.null
csarray or jsonb contains, JSON valuetags=cs.["home"]
not.negates any opdone=not.eq.true
curl -sS -G https://api.atberth.com/v1/apps/$APP/tables/notes/rows \
  -H "Authorization: Bearer $SECRET_KEY" \
  --data-urlencode "done=eq.false" \
  --data-urlencode "priority=gte.2" | jq -c '.rows[] | .title'
curl -sS -G https://api.atberth.com/v1/apps/$APP/tables/notes/rows \
  -H "Authorization: Bearer $SECRET_KEY" \
  --data-urlencode "title=ilike.*BUY*" | jq -c '.rows[] | .title'
curl -sS -G https://api.atberth.com/v1/apps/$APP/tables/notes/rows \
  -H "Authorization: Bearer $SECRET_KEY" \
  --data-urlencode "slug=in.(milk,bread)" | jq -c '.rows[] | .title'
curl -sS -G https://api.atberth.com/v1/apps/$APP/tables/notes/rows \
  -H "Authorization: Bearer $SECRET_KEY" \
  --data-urlencode 'tags=cs.["home"]' | jq -c '.rows[] | .title'
curl -sS -G https://api.atberth.com/v1/apps/$APP/tables/notes/rows \
  -H "Authorization: Bearer $SECRET_KEY" \
  --data-urlencode "priority=is.null" | jq -c '.rows[] | .title'
curl -sS -G https://api.atberth.com/v1/apps/$APP/tables/notes/rows \
  -H "Authorization: Bearer $SECRET_KEY" \
  --data-urlencode "done=not.eq.true" | jq -c '.rows[] | .title'

The CLI takes the same filters as --where column.op=value:

berth rows list --app "$APP" notes --where done.eq=false --where priority.gte=2

There is no or= filter. For OR logic use in.(...) when it is one column, or SQL.

Select and order

select picks columns. order takes column.asc or column.desc, comma separated; nulls sort last. limit is 1 to 1000 (default 100), and offset skips rows.

curl -sS -G https://api.atberth.com/v1/apps/$APP/tables/notes/rows \
  -H "Authorization: Bearer $SECRET_KEY" \
  --data-urlencode "select=id,title,priority" \
  --data-urlencode "order=priority.desc,title.asc" \
  --data-urlencode "limit=3"
berth rows list --app "$APP" notes --select title,priority --order priority.desc --limit 3 --offset 1

Count

count=exact adds "count" to the body and an X-Total-Count header, with the filters applied and before limit.

curl -sS -G https://api.atberth.com/v1/apps/$APP/tables/notes/rows \
  -H "Authorization: Bearer $SECRET_KEY" \
  --data-urlencode "done=eq.false" \
  --data-urlencode "count=exact" \
  --data-urlencode "limit=1" | jq .count
berth rows list --app "$APP" notes --count --limit 1

Cursor pagination

Offsets get slow and skip rows when data changes. Cursors do not. Each page returns next_cursor (also in the Berth-Next-Cursor header) while there is more; pass it back as cursor with the same filters and order.

CURSOR=""
while :; do
  PAGE=$(curl -sS -G https://api.atberth.com/v1/apps/$APP/tables/notes/rows \
    -H "Authorization: Bearer $SECRET_KEY" \
    --data-urlencode "limit=2" \
    ${CURSOR:+--data-urlencode "cursor=$CURSOR"})
  echo "$PAGE" | jq -c '[.rows[].title]'
  CURSOR=$(echo "$PAGE" | jq -r '.next_cursor // empty')
  [ -n "$CURSOR" ] || break
done

The CLI follows cursors for you with --all, or takes one page at a time with --cursor:

berth rows list --app "$APP" notes --limit 2 --all

One row by id

curl -sS https://api.atberth.com/v1/apps/$APP/tables/notes/rows/$ROW_ID \
  -H "Authorization: Bearer $SECRET_KEY"
curl -sS -X PATCH https://api.atberth.com/v1/apps/$APP/tables/notes/rows/$ROW_ID \
  -H "Authorization: Bearer $SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"done":true}'
berth rows get --app "$APP" notes "$ROW_ID"
berth rows update --app "$APP" notes "$ROW_ID" priority=2
berth rows rm --app "$APP" notes "$ROW_ID"

Update and delete by filter

PATCH and DELETE on /rows act on every row that matches. At least one filter is required, so a missing filter cannot wipe a table.

curl -sS -X PATCH "https://api.atberth.com/v1/apps/$APP/tables/notes/rows?slug=in.(milk,bread)" \
  -H "Authorization: Bearer $SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"priority":1}' | jq '.rows | length'
berth rows update --app "$APP" notes --where slug.eq=sink done=true
berth rows delete --app "$APP" notes --where done.eq=true --yes

With curl, DELETE /rows?done=eq.true returns {"deleted": n}.

Upsert

Add upsert=true&on_conflict=column to insert or update by a unique column. The response is 200 with the resulting rows.

curl -sS -X POST "https://api.atberth.com/v1/apps/$APP/tables/notes/rows?upsert=true&on_conflict=slug" \
  -H "Authorization: Bearer $SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '[{"slug":"milk","title":"Buy oat milk","done":false},{"slug":"eggs","title":"Buy eggs","done":false}]'
berth rows upsert --app "$APP" notes --on-conflict slug slug=milk title="Buy oat milk" priority=3

Import CSV or NDJSON

Import up to 20 MB into an existing table. CSV needs a header row with column names. It is all or nothing: one bad row and nothing is inserted.

cat > notes.csv <<'EOF'
title,slug,priority
Plan the trip,trip,2
Pay rent,rent,3
EOF
berth import --app "$APP" notes notes.csv
printf '%s\n' '{"title":"Book flights","slug":"flights"}' '{"title":"Renew passport","slug":"passport","priority":3}' > notes.ndjson
curl -sS -X POST https://api.atberth.com/v1/apps/$APP/tables/notes/import \
  -H "Authorization: Bearer $SECRET_KEY" \
  -H "Content-Type: application/x-ndjson" \
  --data-binary @notes.ndjson

Send CSV to the same route with Content-Type: text/csv. The result is {"inserted": n}.

Safe retries with Idempotency-Key

Phones lose signal mid request. Send an Idempotency-Key on POST, PATCH, PUT, or DELETE, and a retry with the same key and body gets the stored response instead of a second insert. Keys are kept 24 hours per credential. Replays carry Idempotent-Replayed: true. The same key with a different body is 409 idempotency_conflict.

for attempt in 1 2; do
  curl -sS -o /dev/null -D - -X POST https://api.atberth.com/v1/apps/$APP/tables/notes/rows \
    -H "Authorization: Bearer $SECRET_KEY" \
    -H "Content-Type: application/json" \
    -H "Idempotency-Key: note-once-$APP" \
    -d '{"title":"Only once","slug":"once"}' | grep -i -E '^(HTTP|idempotent-replayed)'
done
berth rows list --app "$APP" notes --where slug.eq=once --count