@thadeu/ndb 0.4.0 → 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,50 +66,60 @@ 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`.
113
123
 
114
124
  ### Truncate and drop
115
125
 
@@ -119,12 +129,12 @@ await calls.drop() // the collection, its definition and its watch consumers
119
129
  ```
120
130
 
121
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.
122
- - After `drop()`, the next call on the model declares the collection again. A collection declared in `ndb.yml` comes back, empty, when the daemon restarts.
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.
123
133
 
124
134
  ### Expiry (TTL like Redis)
125
135
 
126
136
  ```ts
127
- const sessions = db.model('sessions', {
137
+ const sessions = ndb.collection('sessions', {
128
138
  expires: 'exp',
129
139
  fields: { exp: { type: 'date', optional: true } },
130
140
  })
@@ -132,7 +142,7 @@ const sessions = db.model('sessions', {
132
142
  await sessions.insert({ _id: 's1' }, { expireIn: '1h' }) // or 3600; all docs of the call
133
143
  await sessions.update('s1', { $set: { user: 'u' } }, { expireIn: '30m' })
134
144
 
135
- const { v, expiresAt } = await sessions.expire('s1', '30m') // EXPIRE
145
+ const { _v, expiresAt } = await sessions.expire('s1', '30m') // EXPIRE
136
146
  await sessions.expireAt('s1', new Date(Date.now() + 3_600_000))
137
147
  await sessions.ttl('s1') // seconds left, -1 = never expires, null = missing or expired
138
148
  await sessions.persist('s1') // PERSIST
@@ -144,12 +154,12 @@ await sessions.persist('s1') // PERSIST
144
154
 
145
155
  ## Query
146
156
 
147
- 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.
148
158
 
149
159
  ```ts
150
160
  const rows = await calls
151
- .where({ status: 'answered', duration: { $gte: 30 } })
152
- .where({ caller: { $in: ['a', 'b'] } })
161
+ .query({ status: 'answered', duration: { $gte: 30 } })
162
+ .query({ caller: { $in: ['a', 'b'] } })
153
163
  .not({ caller: 'zzz' })
154
164
  .or({ duration: { $lt: 5 } }, { 'meta.agent.id': 7 })
155
165
  .sort({ at: -1, _id: 1 })
@@ -158,18 +168,37 @@ const rows = await calls
158
168
  .skip(100)
159
169
  ```
160
170
 
161
- - 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.
162
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.
163
173
  - The order of the `sort` keys is the order of the sort. The id is always the last key.
164
174
  - `fields(...)` returns only these fields. The rows are `Partial`.
165
175
  - `page(n, size)` sets `skip` and `limit` for page `n` (from 1).
166
176
  - `first()` returns the first row, or `null`.
167
- - 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
+ ```
168
196
 
169
197
  ### Count
170
198
 
171
199
  ```ts
172
- 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()
173
202
  const total = await calls.count()
174
203
  ```
175
204
 
@@ -178,19 +207,19 @@ const total = await calls.count()
178
207
  Offset pages get slower as `skip` grows. Use a cursor for deep pages.
179
208
 
180
209
  ```ts
181
- const q = calls.where({ status: 'answered' }).sort({ _id: 1 }).limit(100)
210
+ const q = calls.query({ status: 'answered' }).sort({ _id: 1 }).limit(100)
182
211
  const page = await q
183
212
  const token = q.nextCursor // '' on the last page
184
213
 
185
- 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)
186
215
  ```
187
216
 
188
- 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`.
189
218
 
190
- 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:
191
220
 
192
221
  ```ts
193
- 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()) {
194
223
  console.log(row._id)
195
224
  }
196
225
  ```
@@ -200,7 +229,7 @@ for await (const row of calls.where({ status: 'answered' }).sort({ _id: 1 }).lim
200
229
  There is no explain method. Send `explain: true` in a raw request and read the header:
201
230
 
202
231
  ```ts
203
- 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 })
204
233
 
205
234
  console.log(res.header('x-ndb-plan')) // 'index:status'
206
235
  console.log(res.header('x-ndb-explain')) // JSON string with the counters
@@ -237,7 +266,7 @@ Options: `limit`, `skip`, `after`, `count`. Pass a type to type the rows: `calls
237
266
 
238
267
  ## Watch
239
268
 
240
- 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.
241
270
 
242
271
  ```ts
243
272
  import { HistoryTruncatedError, WatchLaggedError } from '@thadeu/ndb'
@@ -245,8 +274,8 @@ import { HistoryTruncatedError, WatchLaggedError } from '@thadeu/ndb'
245
274
  const controller = new AbortController()
246
275
 
247
276
  try {
248
- for await (const ev of calls.watch('relay', { where: { status: 'answered' }, signal: controller.signal })) {
249
- // ev: { consumer, pos, op: 'put' | 'del' | 'truncate', _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 }
250
279
  await handle(ev)
251
280
 
252
281
  await calls.ack(ev)
@@ -262,12 +291,37 @@ try {
262
291
  }
263
292
  ```
264
293
 
265
- - 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).
266
- - 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.
267
296
  - Delivery is at least once. After a restart an event can come again, so make the work safe to repeat.
268
- - `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.
269
298
  - `HistoryTruncatedError` means the WAL files for the position were deleted (`wal_retention`).
270
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
+
271
325
  ## Errors
272
326
 
273
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).
@@ -284,7 +338,7 @@ Daemon errors are `NdbError` or a subclass. They have `code` (the daemon code) a
284
338
  | `HistoryTruncatedError` | `history_truncated` |
285
339
  | `TtlError` | `ttl_not_enabled`, `ttl_derived` |
286
340
  | `InvalidUpdateError` | `unknown_operator`, `no_update`, `conflicting_update`, `invalid_field`, `unindexed_many`, `empty_filter`, `invalid_update` |
287
- | `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`, ... |
288
342
 
289
343
  ```ts
290
344
  import { NdbError } from '@thadeu/ndb'
@@ -302,7 +356,7 @@ try {
302
356
 
303
357
  ## Testing
304
358
 
305
- 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:
306
360
 
307
361
  ```sh
308
362
  ndb -config /nonexistent.yml -data-dir "$(mktemp -d)" -unix /tmp/ndb-test.sock -tcp 127.0.0.1:7070