@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 +102 -48
- package/dist/index.cjs +208 -151
- package/dist/index.d.cts +53 -29
- package/dist/index.d.ts +53 -29
- package/dist/index.js +207 -150
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
50
|
+
## Define a collection
|
|
51
51
|
|
|
52
52
|
```ts
|
|
53
|
-
const calls = ndb.
|
|
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
|
|
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
|
|
73
|
+
- The client has no `ttl` catalog option. Use the HTTP API for it.
|
|
73
74
|
|
|
74
|
-
## Insert,
|
|
75
|
+
## Insert, get, update, delete
|
|
75
76
|
|
|
76
77
|
```ts
|
|
77
|
-
//
|
|
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: '
|
|
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 /
|
|
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
|
|
88
|
-
//
|
|
89
|
-
const res = await calls.update(
|
|
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(
|
|
102
|
+
await stock.update('a', { $inc: { qty: -1 } }, { guard: { qty: { $gte: 1 } } })
|
|
97
103
|
|
|
98
|
-
//
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
- `
|
|
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
|
|
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
|
|
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 =
|
|
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 {
|
|
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
|
-
.
|
|
152
|
-
.
|
|
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 `
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
217
|
+
A token works only with the same query and `sort`. Another query shape throws `CursorError`.
|
|
189
218
|
|
|
190
|
-
To walk every row, use `
|
|
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.
|
|
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('
|
|
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
|
|
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', {
|
|
249
|
-
// ev: { consumer, pos, op: 'put' | 'del' | 'truncate', _id,
|
|
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), `
|
|
266
|
-
- A delete event has `doc: undefined`. With `
|
|
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 /
|
|
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
|