@thadeu/ndb 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +306 -2
- package/dist/index.cjs +639 -0
- package/dist/index.d.cts +247 -0
- package/dist/index.d.ts +247 -0
- package/dist/index.js +597 -0
- package/package.json +66 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 thadeu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,307 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @thadeu/ndb
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<img src="https://raw.githubusercontent.com/thadeu/ndb/main/brand/png/lockup-on-light.png" width="220">
|
|
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.
|
|
6
|
+
|
|
7
|
+
Node 24 or newer. ESM and CJS. One runtime dependency (`undici`).
|
|
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).
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
pnpm add @thadeu/ndb
|
|
15
|
+
# or
|
|
16
|
+
npm install @thadeu/ndb
|
|
17
|
+
bun add @thadeu/ndb
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
You also need a running ndb daemon. See the repository for how to run one.
|
|
21
|
+
|
|
22
|
+
## Connect
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { connect } from '@thadeu/ndb'
|
|
26
|
+
|
|
27
|
+
// Unix socket.
|
|
28
|
+
const ndb = connect('unix:///run/ndb/ndb.sock')
|
|
29
|
+
|
|
30
|
+
// TCP with a bearer token.
|
|
31
|
+
const remote = connect('tcp://10.0.0.5:7070', { token: 'secret' })
|
|
32
|
+
|
|
33
|
+
// Address and token from the environment: NDB_URL and NDB_TOKEN.
|
|
34
|
+
const fromEnv = connect()
|
|
35
|
+
|
|
36
|
+
// Retry for up to 5 s while the daemon starts or recovers.
|
|
37
|
+
const patient = connect('unix:///run/ndb/ndb.sock', { readyTimeout: 5000 })
|
|
38
|
+
|
|
39
|
+
await ndb.health()
|
|
40
|
+
await ndb.close()
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
- `connect` does not connect. The first call does.
|
|
44
|
+
- The address is `unix:///path` or `tcp://host:port`.
|
|
45
|
+
- The token goes only in the `Authorization` header. A unix socket does not need it.
|
|
46
|
+
- `readyTimeout` is in milliseconds. It sets how long a call retries when the daemon is not ready (`not_ready`, or the socket is missing or refuses). Default 10000. Zero turns the retry off. Writes are retried only when the daemon certainly did not process them. Reads are also retried after a dropped connection.
|
|
47
|
+
- The pool has up to 16 connections. There is no setting for it.
|
|
48
|
+
- `new Ndb(addr, opts)` does the same as `connect`.
|
|
49
|
+
|
|
50
|
+
## Define a model
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
const calls = ndb.model('calls', {
|
|
54
|
+
id: 'call_id',
|
|
55
|
+
time: 'at',
|
|
56
|
+
fields: {
|
|
57
|
+
call_id: { type: 'string' },
|
|
58
|
+
status: { type: 'string', index: true },
|
|
59
|
+
caller: { type: 'string', unique: true },
|
|
60
|
+
duration: { type: 'number', optional: true },
|
|
61
|
+
at: { type: 'date' },
|
|
62
|
+
meta: { type: 'json', optional: true },
|
|
63
|
+
},
|
|
64
|
+
})
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
- Field types: `string`, `number`, `boolean`, `date` (a `Date`), `json` (any value).
|
|
68
|
+
- Field options: `index`, `unique`, `optional`.
|
|
69
|
+
- `id` is the id field. Without it the id is `_id`, made by the daemon.
|
|
70
|
+
- `time` is the collection time field. Used by zone maps and `$dateTrunc`.
|
|
71
|
+
- The model declares its collection on first use (`ensure_collection`). You can call `await calls.ready()` to do it at once.
|
|
72
|
+
- Types come from the schema. Rows have the schema fields and `_v`, the document version. A `date` field is a `Date` in rows. Input takes `Date` values and ISO strings in filters.
|
|
73
|
+
- `expires` is the `date` field that holds when a document expires. It turns on `expireIn`, `expire`, `persist` and `ttl` (below).
|
|
74
|
+
- The client has no `ttl` catalog option and no blob API. Use the HTTP API for those.
|
|
75
|
+
|
|
76
|
+
## Insert, find, update, delete
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
// Insert one or many. All documents go in one transaction.
|
|
80
|
+
const results = await calls.insert([
|
|
81
|
+
{ call_id: 'c1', status: 'answered', caller: 'a', duration: 30, at: new Date() },
|
|
82
|
+
{ call_id: 'c2', status: 'failed', caller: 'b', at: new Date() },
|
|
83
|
+
])
|
|
84
|
+
// results: [{ id: 'c1', v: 1 }, ...]
|
|
85
|
+
|
|
86
|
+
// Find by id. Returns null when missing.
|
|
87
|
+
const row = await calls.find('c1')
|
|
88
|
+
|
|
89
|
+
// Update with optimistic locking.
|
|
90
|
+
await calls.update('c1', { status: 'done' }, { ifVersion: row!._v })
|
|
91
|
+
|
|
92
|
+
// Remove a field.
|
|
93
|
+
await calls.update('c1', {}, { unset: ['duration'] })
|
|
94
|
+
|
|
95
|
+
// Delete. ifVersion is optional here too.
|
|
96
|
+
await calls.delete('c1', { ifVersion: 3 })
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`update` returns `{ id, v }` with the new version. A stale `ifVersion` throws `VersionConflictError`:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
import { VersionConflictError } from '@thadeu/ndb'
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
await calls.update('c1', { status: 'x' }, { ifVersion: row!._v })
|
|
106
|
+
} catch (err) {
|
|
107
|
+
if (err instanceof VersionConflictError) {
|
|
108
|
+
// Someone else wrote first. Read again and retry.
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
An insert with a duplicate id throws `DuplicateIdError`. A duplicate `unique` value throws `UniqueViolationError`. An insert without the id when the schema names one fails with code `missing_id`.
|
|
114
|
+
|
|
115
|
+
### Expiry (TTL like Redis)
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
const sessions = db.model('sessions', {
|
|
119
|
+
id: 'sid',
|
|
120
|
+
expires: 'exp',
|
|
121
|
+
fields: { sid: { type: 'string' }, exp: { type: 'date', optional: true } },
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
await sessions.insert({ sid: 's1' }, { expireIn: '1h' }) // or 3600; all docs of the call
|
|
125
|
+
await sessions.update('s1', { user: 'u' }, { expireIn: '30m' })
|
|
126
|
+
|
|
127
|
+
const { v, expiresAt } = await sessions.expire('s1', '30m') // EXPIRE
|
|
128
|
+
await sessions.expireAt('s1', new Date(Date.now() + 3_600_000))
|
|
129
|
+
await sessions.ttl('s1') // seconds left, -1 = never expires, null = missing or expired
|
|
130
|
+
await sessions.persist('s1') // PERSIST
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
- A duration is seconds or a string such as `"90m"`, `"1h30m"`, `"1d"`. `expireIn` wins over a value of the `expires` field in the same call.
|
|
134
|
+
- `expire` and `persist` throw `NotFoundError` for a missing or expired document. `TtlError` (`ttl_not_enabled`, `ttl_derived`) means the schema has no `expires` field, or the expiry comes from `ttl` + `time`.
|
|
135
|
+
- Disk space of expired documents returns after compaction, and fully only with `wal_retention`; see [Freeing disk](https://github.com/thadeu/ndb/blob/main/docs/dev/configuration.md#freeing-disk).
|
|
136
|
+
|
|
137
|
+
## Query
|
|
138
|
+
|
|
139
|
+
A query is lazy and immutable. It is also thenable: `await` runs it.
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
const rows = await calls
|
|
143
|
+
.where({ status: 'answered', duration: { $gte: 30 } })
|
|
144
|
+
.where({ caller: { $in: ['a', 'b'] } })
|
|
145
|
+
.not({ caller: 'zzz' })
|
|
146
|
+
.or({ duration: { $lt: 5 } }, { 'meta.agent.id': 7 })
|
|
147
|
+
.sort({ at: -1, call_id: 1 })
|
|
148
|
+
.fields('call_id', 'status')
|
|
149
|
+
.limit(50)
|
|
150
|
+
.skip(100)
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
- Operators: `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$in`, `$nin`, `$exists`. Several `where` calls are ANDed. Dot paths work on `json` fields.
|
|
154
|
+
- `$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.
|
|
155
|
+
- The order of the `sort` keys is the order of the sort. The id is always the last key.
|
|
156
|
+
- `fields(...)` returns only these fields. The rows are `Partial`.
|
|
157
|
+
- `page(n, size)` sets `skip` and `limit` for page `n` (from 1).
|
|
158
|
+
- `first()` returns the first row, or `null`.
|
|
159
|
+
- The model has the same shortcuts: `calls.where(...)`, `calls.sort(...)`, `calls.all()`, `calls.count()`.
|
|
160
|
+
|
|
161
|
+
### Count
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
const answered = await calls.where({ status: 'answered' }).count()
|
|
165
|
+
const total = await calls.count()
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Cursor pagination
|
|
169
|
+
|
|
170
|
+
Offset pages get slower as `skip` grows. Use a cursor for deep pages.
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
const q = calls.where({ status: 'answered' }).sort({ call_id: 1 }).limit(100)
|
|
174
|
+
const page = await q
|
|
175
|
+
const token = q.nextCursor // '' on the last page
|
|
176
|
+
|
|
177
|
+
const next = await calls.where({ status: 'answered' }).sort({ call_id: 1 }).limit(100).after(token)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
A token works only with the same `where` and `sort`. Another query shape throws `CursorError`.
|
|
181
|
+
|
|
182
|
+
To walk every row, use `iterate()`. It follows the cursor, with `limit` as the batch size:
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
for await (const row of calls.where({ status: 'answered' }).sort({ call_id: 1 }).limit(500).iterate()) {
|
|
186
|
+
console.log(row.call_id)
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### Explain
|
|
191
|
+
|
|
192
|
+
There is no explain method. Send `explain: true` in a raw request and read the header:
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
const res = await ndb.stream('POST', calls.path('find'), { where: { status: 'failed' }, explain: true, limit: 1 })
|
|
196
|
+
|
|
197
|
+
console.log(res.header('x-ndb-plan')) // 'index:status'
|
|
198
|
+
console.log(res.header('x-ndb-explain')) // JSON string with the counters
|
|
199
|
+
|
|
200
|
+
await res.body.dump()
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## Aggregate
|
|
204
|
+
|
|
205
|
+
The pipeline needs exactly one `$group`. Before it: `$match` and one `$sort`. After it: `$match`, `$sort`, `$limit`, `$skip`, `$project`. Accumulators: `$count`, `$sum`, `$avg`, `$min`, `$max`, `$first`, `$last`.
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
const res = await calls.aggregate(
|
|
209
|
+
[
|
|
210
|
+
{ $match: { status: { $in: ['answered', 'failed'] } } },
|
|
211
|
+
{
|
|
212
|
+
$group: {
|
|
213
|
+
_id: { status: '$status', hour: { $dateTrunc: { date: '$at', unit: 'hour' } } },
|
|
214
|
+
n: { $count: {} },
|
|
215
|
+
avg: { $avg: '$duration' },
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
{ $sort: { n: -1 } },
|
|
219
|
+
],
|
|
220
|
+
{ limit: 100, count: true },
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
res.rows // the group rows
|
|
224
|
+
res.next // cursor for the next page ('' on the last one)
|
|
225
|
+
res.total // with count: true
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Options: `limit`, `skip`, `after`, `count`. Pass a type to type the rows: `calls.aggregate<{ _id: string; n: number }>(...)`. `$dateTrunc` units are `minute`, `hour`, `day`, `week` and `month`.
|
|
229
|
+
|
|
230
|
+
## Watch
|
|
231
|
+
|
|
232
|
+
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.
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
import { HistoryTruncatedError, WatchLaggedError } from '@thadeu/ndb'
|
|
236
|
+
|
|
237
|
+
const controller = new AbortController()
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
for await (const ev of calls.watch('relay', { where: { status: 'answered' }, signal: controller.signal })) {
|
|
241
|
+
// ev: { consumer, pos, op: 'put' | 'del', id, v, doc }
|
|
242
|
+
await handle(ev)
|
|
243
|
+
|
|
244
|
+
await calls.ack(ev)
|
|
245
|
+
}
|
|
246
|
+
} catch (err) {
|
|
247
|
+
if (err instanceof WatchLaggedError) {
|
|
248
|
+
// The consumer was too slow. Call watch again. It continues after the last ack.
|
|
249
|
+
} else if (err instanceof HistoryTruncatedError) {
|
|
250
|
+
// The saved position is too old. Start over.
|
|
251
|
+
} else {
|
|
252
|
+
throw err
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
- 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).
|
|
258
|
+
- 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.
|
|
259
|
+
- Delivery is at least once. After a restart an event can come again, so make the work safe to repeat.
|
|
260
|
+
- `ack` stores the position. The next `watch` with the same consumer name starts after it.
|
|
261
|
+
- `HistoryTruncatedError` means the WAL files for the position were deleted (`wal_retention`).
|
|
262
|
+
|
|
263
|
+
## Errors
|
|
264
|
+
|
|
265
|
+
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).
|
|
266
|
+
|
|
267
|
+
| Class | Codes |
|
|
268
|
+
|---|---|
|
|
269
|
+
| `UnauthorizedError` | `unauthorized` |
|
|
270
|
+
| `NotFoundError` | `not_found`, `unknown_collection` |
|
|
271
|
+
| `VersionConflictError` | `version_conflict` |
|
|
272
|
+
| `DuplicateIdError` | `duplicate_id` |
|
|
273
|
+
| `UniqueViolationError` | `unique_violation` |
|
|
274
|
+
| `InvalidQueryError` | `invalid_query`, `invalid_pipeline` |
|
|
275
|
+
| `CursorError` | `cursor_invalid`, `cursor_mismatch`, `cursor_expired` |
|
|
276
|
+
| `WatchLaggedError` | `watch_lagged` |
|
|
277
|
+
| `HistoryTruncatedError` | `history_truncated` |
|
|
278
|
+
| `TtlError` | `ttl_not_enabled`, `ttl_derived` |
|
|
279
|
+
| `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`, ... |
|
|
280
|
+
|
|
281
|
+
```ts
|
|
282
|
+
import { NdbError } from '@thadeu/ndb'
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
await calls.find('c1')
|
|
286
|
+
} catch (err) {
|
|
287
|
+
if (err instanceof NdbError && err.code === 'not_ready') {
|
|
288
|
+
// The daemon is still recovering, and readyTimeout ran out.
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
`ErrorCode` is the union of the known codes.
|
|
294
|
+
|
|
295
|
+
## Testing
|
|
296
|
+
|
|
297
|
+
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:
|
|
298
|
+
|
|
299
|
+
```sh
|
|
300
|
+
ndb -config /nonexistent.yml -data-dir "$(mktemp -d)" -unix /tmp/ndb-test.sock -tcp 127.0.0.1:7070
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Add `-token-file <path>` to ask for a bearer token on TCP. Use a short socket path: macOS allows 104 bytes. Use a new collection name per test.
|
|
304
|
+
|
|
305
|
+
## License
|
|
306
|
+
|
|
307
|
+
MIT.
|