@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 +111 -47
- package/dist/index.cjs +231 -131
- package/dist/index.d.cts +72 -25
- package/dist/index.d.ts +72 -25
- package/dist/index.js +230 -130
- 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,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
|
|
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`.
|
|
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 =
|
|
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 {
|
|
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
|
-
.
|
|
142
|
-
.
|
|
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 `
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
217
|
+
A token works only with the same query and `sort`. Another query shape throws `CursorError`.
|
|
179
218
|
|
|
180
|
-
To walk every row, use `
|
|
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.
|
|
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('
|
|
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
|
|
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', {
|
|
239
|
-
// ev: { consumer, pos, op: 'put' | 'del', _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 }
|
|
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), `
|
|
256
|
-
- 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.
|
|
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 /
|
|
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
|