@thadeu/ndb 0.3.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,11 +2,11 @@
2
2
 
3
3
  <img src="https://raw.githubusercontent.com/thadeu/ndb/main/brand/png/lockup-on-light.png" width="220">
4
4
 
5
- TypeScript client for [ndb](https://github.com/thadeu/ndb), an NDJSON query daemon. Typed models, a chainable query builder, aggregate and watch. The client builds JSON. The daemon decides what every query means.
5
+ TypeScript client for [ndb](https://github.com/thadeu/ndb), an NDJSON query daemon. Typed collections, a chainable query builder, aggregate, watch and blobs. The client builds JSON. The daemon decides what every query means.
6
6
 
7
7
  Node 24 or newer. ESM and CJS. One runtime dependency (`undici`).
8
8
 
9
- Docs: [client guide](https://github.com/thadeu/ndb/blob/main/docs/dev/clients.md#typescript-package-thadeundb), [query language](https://github.com/thadeu/ndb/blob/main/docs/dev/query.md), [HTTP API](https://github.com/thadeu/ndb/blob/main/docs/dev/http-api.md).
9
+ Docs: [client guide](https://github.com/thadeu/ndb/blob/main/docs/dev/clients.md#typescript-package-thadeundb), [query language](https://github.com/thadeu/ndb/blob/main/docs/dev/query.md), [HTTP API](https://github.com/thadeu/ndb/blob/main/docs/dev/http.md).
10
10
 
11
11
  ## Install
12
12
 
@@ -36,7 +36,7 @@ const fromEnv = connect()
36
36
  // Retry for up to 5 s while the daemon starts or recovers.
37
37
  const patient = connect('unix:///run/ndb/ndb.sock', { readyTimeout: 5000 })
38
38
 
39
- await ndb.health()
39
+ await ndb.health() // resolves when the daemon is ready (GET /_health)
40
40
  await ndb.close()
41
41
  ```
42
42
 
@@ -47,10 +47,10 @@ await ndb.close()
47
47
  - The pool has up to 16 connections. There is no setting for it.
48
48
  - `new Ndb(addr, opts)` does the same as `connect`.
49
49
 
50
- ## Define a model
50
+ ## Define a collection
51
51
 
52
52
  ```ts
53
- const calls = ndb.model('calls', {
53
+ const calls = ndb.collection('calls', {
54
54
  time: 'at',
55
55
  fields: {
56
56
  status: { type: 'string', index: true },
@@ -66,55 +66,75 @@ const calls = ndb.model('calls', {
66
66
  - Field options: `index`, `unique`, `optional`.
67
67
  - `_id` is the primary key of every document. It is not a schema field: leave it out on insert and the daemon makes a UUIDv7 (or use `newId()`). For a natural key (email, SKU), keep `_id` and mark that field `unique`.
68
68
  - `time` is the collection time field. Used by zone maps and `$dateTrunc`.
69
- - The model declares its collection on first use (`ensure_collection`). You can call `await calls.ready()` to do it at once.
69
+ - The collection is declared on first use (`PUT /calls`). You can call `await calls.ready()` to do it at once.
70
+ - A name that ends with `*` is a collection pattern: `ndb.collection('logs-2026-*', schema)` reads every collection whose name starts with `logs-2026-`. It declares nothing, and a write throws with code `pattern_read_only`. Each row has `_coll`, the name of its collection.
70
71
  - Types come from the schema. Rows have the schema fields, `_id` and `_v`, the document version. A `date` field is a `Date` in rows. Input takes `Date` values and ISO strings in filters.
71
72
  - `expires` is the `date` field that holds when a document expires. It turns on `expireIn`, `expire`, `persist` and `ttl` (below).
72
- - The client has no `ttl` catalog option and no blob API. Use the HTTP API for those.
73
+ - The client has no `ttl` catalog option. Use the HTTP API for it.
73
74
 
74
- ## Insert, find, update, delete
75
+ ## Insert, get, update, delete
75
76
 
76
77
  ```ts
77
- // Insert one or many. All documents go in one transaction.
78
+ // One document (POST /calls/_doc): { _id, _v }.
79
+ const one = await calls.insert({ _id: 'c1', status: 'answered', caller: 'a', duration: 30, at: new Date() })
80
+
81
+ // Many in one transaction, all or nothing (POST /calls/_bulk): an array.
78
82
  const results = await calls.insert([
79
- { _id: 'c1', status: 'answered', caller: 'a', duration: 30, at: new Date() },
80
83
  { _id: 'c2', status: 'failed', caller: 'b', at: new Date() },
84
+ { _id: 'c3', status: 'failed', caller: 'c', at: new Date() },
81
85
  ])
82
- // results: [{ _id: 'c1', v: 1 }, ...]
86
+ // results: [{ _id: 'c2', _v: 1 }, { _id: 'c3', _v: 1 }]
87
+
88
+ // Upsert: create by _id, or merge the fields into the stored document (the
89
+ // others stay), like doc_as_upsert in Elasticsearch. One transaction.
90
+ await calls.upsert({ _id: 'c2', status: 'answered' })
91
+ await calls.upsertAll([{ _id: 'c2', status: 'answered' }, { _id: 'c4', status: 'new', at: new Date() }])
83
92
 
84
- // Get by _id (GET /v1/c/calls/c1). Returns null when missing.
93
+ // Get by _id (GET /calls/_doc/c1). Returns null when missing.
85
94
  const row = await calls.get('c1')
86
95
 
87
- // Atomic update by filter: filter first, then the write operators.
88
- // _v in the filter is an optimistic lock.
89
- const res = await calls.update({ _id: 'c1', _v: row!._v }, { $set: { status: 'done' }, $unset: { duration: true } })
96
+ // Atomic update by _id (POST /calls/_update/c1). guard adds conditions on
97
+ // the same document: a _v guard is an optimistic lock.
98
+ const res = await calls.update('c1', { $set: { status: 'done' }, $unset: { duration: true } }, { guard: { _v: row!._v } })
90
99
  // res: { matched, modified, doc } (doc is the new row with _v; absent for many)
91
100
 
92
- // A string is an _id (POST /{_id}/update). The same guard rules apply.
93
- await calls.update('c1', { $inc: { duration: 1 } })
94
-
95
101
  // A guarded decrement: exactly one of two racing calls wins.
96
- await stock.update({ _id: 'a', qty: { $gte: 1 } }, { $inc: { qty: -1 } })
102
+ await stock.update('a', { $inc: { qty: -1 } }, { guard: { qty: { $gte: 1 } } })
97
103
 
98
- // Every match (the filter needs _id or an indexed field), or create it.
99
- await calls.update({ status: 'failed' }, { $set: { status: 'retry' } }, { many: true })
104
+ // Create the document with this _id when it is missing.
100
105
  await calls.update('c9', { $inc: { duration: 1 } }, { upsert: true })
101
106
 
102
- // Delete by _id or by filter: { deleted }.
107
+ // Atomic update by query (POST /calls/_update_by_query): the first match, or
108
+ // every match with many (the query needs _id or an indexed field).
109
+ await calls.updateBy({ status: 'failed' }, { $set: { status: 'retry' } }, { many: true })
110
+
111
+ // Delete by _id (DELETE /calls/_doc/c2), or by query: { deleted }.
103
112
  await calls.delete('c2')
104
- await calls.delete({ status: 'failed' }, { many: true })
113
+ await calls.deleteBy({ status: 'failed' }, { many: true })
105
114
  ```
106
115
 
107
116
  - Operators: `$set`, `$setOnInsert` (like `$set`, but only when an upsert creates the document), `$unset`, `$inc`, `$push` (with `{ $each: [...] }`), `$pull`, `$min`, `$max`. Field names can be dot paths.
108
117
  - Nothing matched is not an error: `matched` is 0 (for example when `_v` is stale, so read again and retry). `modified` is 0 when the operators leave the document as it is.
109
- - `many` needs `_id` or an indexed field in the filter, and does not apply to an `_id` string. `upsert` creates the document from the equality fields of the filter (or with that `_id`).
118
+ - `update` takes `guard`, `upsert` (creates the document with that `_id`) and `expireIn`. `updateBy` takes `many`, `upsert` (creates the document from the equality fields of the query) and `expireIn`. `deleteBy` takes `many`.
119
+ - `many` needs `_id` or an indexed field in the query. An empty query is refused (`empty_filter`).
110
120
  - A key of the operators without `$` throws `InvalidUpdateError` before anything is sent. The daemon contract errors (`unknown_operator`, `no_update`, `conflicting_update`, `invalid_field`, `unindexed_many`, `empty_filter`) are `InvalidUpdateError` too.
111
121
 
112
- An insert with a duplicate `_id` throws `DuplicateIdError`. A duplicate `unique` value throws `UniqueViolationError`. An empty or non-string `_id` fails with code `invalid_id`.
122
+ An insert with a duplicate `_id` throws `DuplicateIdError` (a `_bulk` insert then writes nothing). A duplicate `unique` value throws `UniqueViolationError`. An empty or non-string `_id` fails with code `invalid_id`.
123
+
124
+ ### Truncate and drop
125
+
126
+ ```ts
127
+ await calls.truncate() // every document goes; the definition and indexes stay
128
+ await calls.drop() // the collection, its definition and its watch consumers go
129
+ ```
130
+
131
+ - Both take the same time for 10 or 10 million documents. Watch consumers get one event with `op: 'truncate'` (`_id` is `''`), then the new changes.
132
+ - After `drop()`, the next call on the collection declares it again. A collection declared in `ndb.yml` comes back, empty, when the daemon restarts.
113
133
 
114
134
  ### Expiry (TTL like Redis)
115
135
 
116
136
  ```ts
117
- const sessions = db.model('sessions', {
137
+ const sessions = ndb.collection('sessions', {
118
138
  expires: 'exp',
119
139
  fields: { exp: { type: 'date', optional: true } },
120
140
  })
@@ -122,7 +142,7 @@ const sessions = db.model('sessions', {
122
142
  await sessions.insert({ _id: 's1' }, { expireIn: '1h' }) // or 3600; all docs of the call
123
143
  await sessions.update('s1', { $set: { user: 'u' } }, { expireIn: '30m' })
124
144
 
125
- const { v, expiresAt } = await sessions.expire('s1', '30m') // EXPIRE
145
+ const { _v, expiresAt } = await sessions.expire('s1', '30m') // EXPIRE
126
146
  await sessions.expireAt('s1', new Date(Date.now() + 3_600_000))
127
147
  await sessions.ttl('s1') // seconds left, -1 = never expires, null = missing or expired
128
148
  await sessions.persist('s1') // PERSIST
@@ -134,12 +154,12 @@ await sessions.persist('s1') // PERSIST
134
154
 
135
155
  ## Query
136
156
 
137
- A query is lazy and immutable. It is also thenable: `await` runs it.
157
+ A query is lazy and immutable (`POST /{coll}/_search`). It is also thenable: `await` runs it.
138
158
 
139
159
  ```ts
140
160
  const rows = await calls
141
- .where({ status: 'answered', duration: { $gte: 30 } })
142
- .where({ caller: { $in: ['a', 'b'] } })
161
+ .query({ status: 'answered', duration: { $gte: 30 } })
162
+ .query({ caller: { $in: ['a', 'b'] } })
143
163
  .not({ caller: 'zzz' })
144
164
  .or({ duration: { $lt: 5 } }, { 'meta.agent.id': 7 })
145
165
  .sort({ at: -1, _id: 1 })
@@ -148,18 +168,37 @@ const rows = await calls
148
168
  .skip(100)
149
169
  ```
150
170
 
151
- - Operators: `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$in`, `$nin`, `$exists`. Several `where` calls are ANDed. Dot paths work on `json` fields.
171
+ - Operators: `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$in`, `$nin`, `$exists`. Several `query` calls are ANDed. Dot paths work on `json` fields.
152
172
  - `$regex` (with `$options` `i`, `m`, `s`, `x`) uses Go RE2: no backreferences, no look-around. Pass a string, or a `RegExp` (`{ name: /^al/i }` or `{ name: { $regex: /^al/i } }`). Only the `i`, `m` and `s` flags are accepted; any other flag (`g`, `y`, `u`, ...) throws `InvalidQueryError` before the request. An anchored prefix such as `^abc` can use an index.
153
173
  - The order of the `sort` keys is the order of the sort. The id is always the last key.
154
174
  - `fields(...)` returns only these fields. The rows are `Partial`.
155
175
  - `page(n, size)` sets `skip` and `limit` for page `n` (from 1).
156
176
  - `first()` returns the first row, or `null`.
157
- - The model has the same shortcuts: `calls.where(...)`, `calls.sort(...)`, `calls.all()`, `calls.count()`.
177
+ - The collection has the same shortcuts: `calls.query(...)`, `calls.sort(...)`, `calls.all()`, `calls.first()`, `calls.count(...)`, `calls.each()`.
178
+
179
+ ### A query built elsewhere
180
+
181
+ A query from a search form or a saved search is not type-checked. `rawQuery` sends it as is (the daemon validates it), ANDed with the other clauses:
182
+
183
+ ```ts
184
+ const rows = await calls.rawQuery({ status: 'answered', at: { $gte: 'now-7d' } }).limit(50)
185
+ ```
186
+
187
+ For a whole search body (`query`, `sort`, `limit`, `after`, `count`), use `search`. It answers one page:
188
+
189
+ ```ts
190
+ const page = await calls.search({ query: { status: 'answered' }, sort: { at: -1 }, limit: 50, count: true })
191
+
192
+ page.rows // decoded like the rows of query()
193
+ page.next // cursor for the next page ('' on the last one)
194
+ page.total // with count: true
195
+ ```
158
196
 
159
197
  ### Count
160
198
 
161
199
  ```ts
162
- const answered = await calls.where({ status: 'answered' }).count()
200
+ const answered = await calls.count({ status: 'answered' }) // POST /calls/_count
201
+ const failed = await calls.query({ status: 'failed' }).count()
163
202
  const total = await calls.count()
164
203
  ```
165
204
 
@@ -168,19 +207,19 @@ const total = await calls.count()
168
207
  Offset pages get slower as `skip` grows. Use a cursor for deep pages.
169
208
 
170
209
  ```ts
171
- const q = calls.where({ status: 'answered' }).sort({ _id: 1 }).limit(100)
210
+ const q = calls.query({ status: 'answered' }).sort({ _id: 1 }).limit(100)
172
211
  const page = await q
173
212
  const token = q.nextCursor // '' on the last page
174
213
 
175
- const next = await calls.where({ status: 'answered' }).sort({ _id: 1 }).limit(100).after(token)
214
+ const next = await calls.query({ status: 'answered' }).sort({ _id: 1 }).limit(100).after(token)
176
215
  ```
177
216
 
178
- A token works only with the same `where` and `sort`. Another query shape throws `CursorError`.
217
+ A token works only with the same query and `sort`. Another query shape throws `CursorError`.
179
218
 
180
- To walk every row, use `iterate()`. It follows the cursor, with `limit` as the batch size:
219
+ To walk every row, use `each()`. It follows the cursor, with `limit` as the batch size:
181
220
 
182
221
  ```ts
183
- for await (const row of calls.where({ status: 'answered' }).sort({ _id: 1 }).limit(500).iterate()) {
222
+ for await (const row of calls.query({ status: 'answered' }).sort({ _id: 1 }).limit(500).each()) {
184
223
  console.log(row._id)
185
224
  }
186
225
  ```
@@ -190,7 +229,7 @@ for await (const row of calls.where({ status: 'answered' }).sort({ _id: 1 }).lim
190
229
  There is no explain method. Send `explain: true` in a raw request and read the header:
191
230
 
192
231
  ```ts
193
- const res = await ndb.stream('POST', calls.path('find'), { where: { status: 'failed' }, explain: true, limit: 1 })
232
+ const res = await ndb.stream('POST', calls.path('_search'), { query: { status: 'failed' }, explain: true, limit: 1 })
194
233
 
195
234
  console.log(res.header('x-ndb-plan')) // 'index:status'
196
235
  console.log(res.header('x-ndb-explain')) // JSON string with the counters
@@ -227,7 +266,7 @@ Options: `limit`, `skip`, `after`, `count`. Pass a type to type the rows: `calls
227
266
 
228
267
  ## Watch
229
268
 
230
- A watch streams the changes of a model to a named consumer. First it sends the events after the last ack. Then it sends new events as they happen.
269
+ A watch streams the changes of a collection to a named consumer. First it sends the events after the last ack. Then it sends new events as they happen.
231
270
 
232
271
  ```ts
233
272
  import { HistoryTruncatedError, WatchLaggedError } from '@thadeu/ndb'
@@ -235,8 +274,8 @@ import { HistoryTruncatedError, WatchLaggedError } from '@thadeu/ndb'
235
274
  const controller = new AbortController()
236
275
 
237
276
  try {
238
- for await (const ev of calls.watch('relay', { where: { status: 'answered' }, signal: controller.signal })) {
239
- // ev: { consumer, pos, op: 'put' | 'del', _id, v, doc }
277
+ for await (const ev of calls.watch('relay', { query: { status: 'answered' }, signal: controller.signal })) {
278
+ // ev: { consumer, pos, op: 'put' | 'del' | 'truncate', _id, _v, doc }
240
279
  await handle(ev)
241
280
 
242
281
  await calls.ack(ev)
@@ -252,12 +291,37 @@ try {
252
291
  }
253
292
  ```
254
293
 
255
- - Options: `from: 'now'` (a new consumer starts at the end), `limit` (stop after n events), `where` (same filters as a query), `signal` (an `AbortSignal` that stops the stream).
256
- - A delete event has `doc: undefined`. With `where`, a delete is sent only if the same stream saw an earlier matching put of that id.
294
+ - Options: `from: 'now'` (a new consumer starts at the end), `limit` (stop after n events), `query` (same filters as a query), `signal` (an `AbortSignal` that stops the stream).
295
+ - A delete event has `doc: undefined`. With `query`, a delete is sent only if the same stream saw an earlier matching put of that id.
257
296
  - Delivery is at least once. After a restart an event can come again, so make the work safe to repeat.
258
- - `ack` stores the position. The next `watch` with the same consumer name starts after it.
297
+ - `ack(ev)` stores the position (`POST /{coll}/_ack`). The next `watch` with the same consumer name starts after it.
259
298
  - `HistoryTruncatedError` means the WAL files for the position were deleted (`wal_retention`).
260
299
 
300
+ ## Blobs
301
+
302
+ Large values live in the blob store. A document keeps only a reference. The same content always gives the same reference.
303
+
304
+ ```ts
305
+ const blob = await ndb.putBlob(bytes, 'audio/wav') // POST /_blobs: { $blob, size, type }
306
+
307
+ await calls.update('c1', { $set: { recording: blob } })
308
+
309
+ const data = await ndb.getBlob(blob) // GET /_blobs/{ref}: a Uint8Array
310
+ ```
311
+
312
+ ## Admin
313
+
314
+ ```ts
315
+ await calls.stats() // rows, indexes, files, bytes (GET /calls/_stats)
316
+ await calls.compact() // merge every segment now (POST /calls/_compact)
317
+ await calls.ensureIndex('caller') // built in the background (PUT /calls/_indexes/caller); the indexed fields
318
+ await calls.dropIndex('caller')
319
+
320
+ await ndb.collections() // one definition per collection (GET /_collections)
321
+ await ndb.stats() // every collection, totals, version, uptime (GET /_stats)
322
+ await ndb.size() // disk use per collection and part (GET /_size)
323
+ ```
324
+
261
325
  ## Errors
262
326
 
263
327
  Daemon errors are `NdbError` or a subclass. They have `code` (the daemon code) and `status` (the HTTP status; 0 for an error line inside a stream).
@@ -274,7 +338,7 @@ Daemon errors are `NdbError` or a subclass. They have `code` (the daemon code) a
274
338
  | `HistoryTruncatedError` | `history_truncated` |
275
339
  | `TtlError` | `ttl_not_enabled`, `ttl_derived` |
276
340
  | `InvalidUpdateError` | `unknown_operator`, `no_update`, `conflicting_update`, `invalid_field`, `unindexed_many`, `empty_filter`, `invalid_update` |
277
- | `NdbError` | every other code: `not_ready`, `catalog_conflict`, `catalog_invalid`, `direct_collection`, `invalid_request`, `group_limit_exceeded`, `request_too_large`, `line_too_large`, `closed`, `invalid_position`, `http_status`, ... |
341
+ | `NdbError` | every other code: `not_ready`, `catalog_conflict`, `catalog_invalid`, `direct_collection`, `invalid_request`, `group_limit_exceeded`, `request_too_large`, `line_too_large`, `closed`, `invalid_position`, `confirm_required`, `unknown_route`, `pattern_read_only`, `http_status`, ... |
278
342
 
279
343
  ```ts
280
344
  import { NdbError } from '@thadeu/ndb'
@@ -292,7 +356,7 @@ try {
292
356
 
293
357
  ## Testing
294
358
 
295
- The tests of this package (vitest) build the daemon from the repository (`go build ./cmd/ndb`) in a global setup. They start it on a temp unix socket and a free TCP port, wait for `GET /health` to return 200, and stop it at the end. To do the same by hand:
359
+ The tests of this package (vitest) build the daemon from the repository (`go build ./cmd/ndb`) in a global setup. They start it on a temp unix socket and a free TCP port, wait for `GET /_health` to return 200, and stop it at the end. To do the same by hand:
296
360
 
297
361
  ```sh
298
362
  ndb -config /nonexistent.yml -data-dir "$(mktemp -d)" -unix /tmp/ndb-test.sock -tcp 127.0.0.1:7070