@thadeu/ndb 0.1.0 → 0.1.2

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
@@ -70,7 +70,8 @@ const calls = ndb.model('calls', {
70
70
  - `time` is the collection time field. Used by zone maps and `$dateTrunc`.
71
71
  - The model declares its collection on first use (`ensure_collection`). You can call `await calls.ready()` to do it at once.
72
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
- - The client has no `expires` or `ttl` option and no blob API. Use the HTTP API for those.
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.
74
75
 
75
76
  ## Insert, find, update, delete
76
77
 
@@ -85,31 +86,51 @@ const results = await calls.insert([
85
86
  // Find by id. Returns null when missing.
86
87
  const row = await calls.find('c1')
87
88
 
88
- // Update with optimistic locking.
89
- await calls.update('c1', { status: 'done' }, { ifVersion: row!._v })
89
+ // Atomic update by filter: filter first, then the write operators.
90
+ // _v in the filter is an optimistic lock.
91
+ const res = await calls.update({ call_id: 'c1', _v: row!._v }, { $set: { status: 'done' }, $unset: { duration: true } })
92
+ // res: { matched, modified, doc } (doc is the new row with _v; absent for many)
90
93
 
91
- // Remove a field.
92
- await calls.update('c1', {}, { unset: ['duration'] })
94
+ // A guarded decrement: exactly one of two racing calls wins.
95
+ await stock.update({ sku: 'a', qty: { $gte: 1 } }, { $inc: { qty: -1 } })
93
96
 
94
- // Delete. ifVersion is optional here too.
95
- await calls.delete('c1', { ifVersion: 3 })
97
+ // Every match (the filter needs the id or an indexed field), or create it.
98
+ await calls.update({ status: 'failed' }, { $set: { status: 'retry' } }, { many: true })
99
+ await calls.update({ call_id: 'c9' }, { $inc: { duration: 1 } }, { upsert: true })
100
+
101
+ // Delete by filter: { deleted }.
102
+ await calls.delete({ call_id: 'c2' })
103
+ await calls.delete({ status: 'failed' }, { many: true })
96
104
  ```
97
105
 
98
- `update` returns `{ id, v }` with the new version. A stale `ifVersion` throws `VersionConflictError`:
106
+ - Operators: `$set`, `$unset`, `$inc`, `$push` (with `{ $each: [...] }`), `$pull`, `$min`, `$max`. Field names can be dot paths.
107
+ - 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.
108
+ - `many` needs the id or an indexed field in the filter. `upsert` creates the document from the equality fields of the filter.
109
+ - 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.
110
+
111
+ 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`.
112
+
113
+ ### Expiry (TTL like Redis)
99
114
 
100
115
  ```ts
101
- import { VersionConflictError } from '@thadeu/ndb'
116
+ const sessions = db.model('sessions', {
117
+ id: 'sid',
118
+ expires: 'exp',
119
+ fields: { sid: { type: 'string' }, exp: { type: 'date', optional: true } },
120
+ })
102
121
 
103
- try {
104
- await calls.update('c1', { status: 'x' }, { ifVersion: row!._v })
105
- } catch (err) {
106
- if (err instanceof VersionConflictError) {
107
- // Someone else wrote first. Read again and retry.
108
- }
109
- }
122
+ await sessions.insert({ sid: 's1' }, { expireIn: '1h' }) // or 3600; all docs of the call
123
+ await sessions.update({ sid: 's1' }, { $set: { user: 'u' } }, { expireIn: '30m' })
124
+
125
+ const { v, expiresAt } = await sessions.expire('s1', '30m') // EXPIRE
126
+ await sessions.expireAt('s1', new Date(Date.now() + 3_600_000))
127
+ await sessions.ttl('s1') // seconds left, -1 = never expires, null = missing or expired
128
+ await sessions.persist('s1') // PERSIST
110
129
  ```
111
130
 
112
- 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`.
131
+ - 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.
132
+ - `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`.
133
+ - 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).
113
134
 
114
135
  ## Query
115
136
 
@@ -245,13 +266,14 @@ Daemon errors are `NdbError` or a subclass. They have `code` (the daemon code) a
245
266
  |---|---|
246
267
  | `UnauthorizedError` | `unauthorized` |
247
268
  | `NotFoundError` | `not_found`, `unknown_collection` |
248
- | `VersionConflictError` | `version_conflict` |
249
269
  | `DuplicateIdError` | `duplicate_id` |
250
270
  | `UniqueViolationError` | `unique_violation` |
251
271
  | `InvalidQueryError` | `invalid_query`, `invalid_pipeline` |
252
272
  | `CursorError` | `cursor_invalid`, `cursor_mismatch`, `cursor_expired` |
253
273
  | `WatchLaggedError` | `watch_lagged` |
254
274
  | `HistoryTruncatedError` | `history_truncated` |
275
+ | `TtlError` | `ttl_not_enabled`, `ttl_derived` |
276
+ | `InvalidUpdateError` | `unknown_operator`, `no_update`, `conflicting_update`, `invalid_field`, `unindexed_many`, `empty_filter`, `invalid_update` |
255
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`, ... |
256
278
 
257
279
  ```ts
package/dist/index.cjs CHANGED
@@ -24,14 +24,15 @@ __export(index_exports, {
24
24
  DuplicateIdError: () => DuplicateIdError,
25
25
  HistoryTruncatedError: () => HistoryTruncatedError,
26
26
  InvalidQueryError: () => InvalidQueryError,
27
+ InvalidUpdateError: () => InvalidUpdateError,
27
28
  Model: () => Model,
28
29
  Ndb: () => Ndb,
29
30
  NdbError: () => NdbError,
30
31
  NotFoundError: () => NotFoundError,
31
32
  Query: () => Query,
33
+ TtlError: () => TtlError,
32
34
  UnauthorizedError: () => UnauthorizedError,
33
35
  UniqueViolationError: () => UniqueViolationError,
34
- VersionConflictError: () => VersionConflictError,
35
36
  WatchLaggedError: () => WatchLaggedError,
36
37
  connect: () => connect,
37
38
  makeError: () => makeError
@@ -53,8 +54,6 @@ var UnauthorizedError = class extends NdbError {
53
54
  };
54
55
  var NotFoundError = class extends NdbError {
55
56
  };
56
- var VersionConflictError = class extends NdbError {
57
- };
58
57
  var DuplicateIdError = class extends NdbError {
59
58
  };
60
59
  var UniqueViolationError = class extends NdbError {
@@ -67,11 +66,14 @@ var WatchLaggedError = class extends NdbError {
67
66
  };
68
67
  var HistoryTruncatedError = class extends NdbError {
69
68
  };
69
+ var InvalidUpdateError = class extends NdbError {
70
+ };
71
+ var TtlError = class extends NdbError {
72
+ };
70
73
  var subclasses = {
71
74
  unauthorized: UnauthorizedError,
72
75
  not_found: NotFoundError,
73
76
  unknown_collection: NotFoundError,
74
- version_conflict: VersionConflictError,
75
77
  duplicate_id: DuplicateIdError,
76
78
  unique_violation: UniqueViolationError,
77
79
  invalid_query: InvalidQueryError,
@@ -80,7 +82,15 @@ var subclasses = {
80
82
  cursor_mismatch: CursorError,
81
83
  cursor_expired: CursorError,
82
84
  watch_lagged: WatchLaggedError,
83
- history_truncated: HistoryTruncatedError
85
+ history_truncated: HistoryTruncatedError,
86
+ ttl_not_enabled: TtlError,
87
+ ttl_derived: TtlError,
88
+ unknown_operator: InvalidUpdateError,
89
+ no_update: InvalidUpdateError,
90
+ conflicting_update: InvalidUpdateError,
91
+ invalid_field: InvalidUpdateError,
92
+ unindexed_many: InvalidUpdateError,
93
+ empty_filter: InvalidUpdateError
84
94
  };
85
95
  function makeError(code, message, status = 0) {
86
96
  const Cls = subclasses[code] ?? NdbError;
@@ -274,25 +284,71 @@ var Model = class {
274
284
  });
275
285
  return this.ensured;
276
286
  }
277
- async insert(docs) {
287
+ async insert(docs, opts = {}) {
278
288
  await this.ready();
279
289
  const list = Array.isArray(docs) ? docs : [docs];
280
290
  const out = await this.db.transport.request("POST", this.path("insert"), {
281
- insert: list
291
+ insert: list,
292
+ expire_in: opts.expireIn
282
293
  });
283
294
  return out.results;
284
295
  }
285
- create(docs) {
286
- return this.insert(docs);
296
+ create(docs, opts = {}) {
297
+ return this.insert(docs, opts);
298
+ }
299
+ // Atomic update by filter. The filter and the operators are sent as one
300
+ // body; the daemon matches and writes in one step on the newest state, so
301
+ // concurrent updates never interleave. A version guard is a filter:
302
+ // { call_id: 'c1', _v: 7 }. Matching nothing is not an error: matched is 0.
303
+ async update(filter, ops, opts = {}) {
304
+ await this.ready();
305
+ const body = mergeUpdate(filter, ops);
306
+ const query = writeQuery({ many: opts.many, upsert: opts.upsert, expire_in: opts.expireIn });
307
+ const out = await this.db.transport.request(
308
+ "POST",
309
+ this.path("update") + query,
310
+ body
311
+ );
312
+ return { matched: out.matched, modified: out.modified, doc: out.doc && this.decode(out.doc) };
287
313
  }
288
- async update(id, set, opts = {}) {
314
+ // Deletes the documents that the filter matches: one, or every match with
315
+ // many (same rule as update).
316
+ async delete(filter, opts = {}) {
289
317
  await this.ready();
290
- const update = { id, set, unset: opts.unset, if_version: opts.ifVersion };
291
- return this.db.transport.request("POST", this.path("update"), { update });
318
+ return this.db.transport.request("POST", this.path("delete") + writeQuery({ many: opts.many }), toWhere(filter));
292
319
  }
293
- async delete(id, opts = {}) {
320
+ // Makes a document expire, like EXPIRE in Redis: seconds or "30m". It throws
321
+ // NotFoundError for a missing or expired document, and TtlError when the
322
+ // schema has no expires field.
323
+ expire(id, duration) {
324
+ return this.setExpiry({ id, in: duration });
325
+ }
326
+ expireAt(id, at) {
327
+ return this.setExpiry({ id, at: at.toISOString() });
328
+ }
329
+ // Removes the expiry of a document (PERSIST in Redis).
330
+ async persist(id) {
294
331
  await this.ready();
295
- await this.db.transport.request("POST", this.path("delete"), { delete: { id, if_version: opts.ifVersion } });
332
+ return this.db.transport.request("POST", this.path("persist"), { id });
333
+ }
334
+ // The seconds left (rounded up), -1 when the document never expires, null
335
+ // when it is missing or expired.
336
+ async ttl(id) {
337
+ await this.ready();
338
+ try {
339
+ const out = await this.db.transport.request("GET", `${this.path("ttl")}/${encodeURIComponent(id)}`);
340
+ return out.ttl;
341
+ } catch (err) {
342
+ if (err instanceof NotFoundError) {
343
+ return null;
344
+ }
345
+ throw err;
346
+ }
347
+ }
348
+ async setExpiry(body) {
349
+ await this.ready();
350
+ const out = await this.db.transport.request("POST", this.path("expire"), body);
351
+ return { id: out.id, v: out.v, expiresAt: new Date(out.expires_at) };
296
352
  }
297
353
  find(id) {
298
354
  return this.query().where({ [this.idField]: id }).first();
@@ -374,11 +430,30 @@ var Model = class {
374
430
  return {
375
431
  id: this.schema.id,
376
432
  time: this.schema.time,
433
+ expires_field: this.schema.expires,
377
434
  indexes: entries.filter(([, d]) => d.index).map(([n]) => n),
378
435
  unique: entries.filter(([, d]) => d.unique).map(([n]) => n)
379
436
  };
380
437
  }
381
438
  };
439
+ function writeQuery(params) {
440
+ const q = new URLSearchParams();
441
+ for (const [name, value] of Object.entries(params)) {
442
+ if (value !== void 0 && value !== false) {
443
+ q.set(name, String(value));
444
+ }
445
+ }
446
+ const text = q.toString();
447
+ return text === "" ? "" : `?${text}`;
448
+ }
449
+ function mergeUpdate(filter, ops) {
450
+ for (const key of Object.keys(ops)) {
451
+ if (!key.startsWith("$")) {
452
+ throw new InvalidUpdateError("invalid_update", `update operator "${key}" must start with $ (for example "$set")`);
453
+ }
454
+ }
455
+ return { ...toWhere(filter), ...ops };
456
+ }
382
457
 
383
458
  // src/transport.ts
384
459
  var import_undici = require("undici");
@@ -584,14 +659,15 @@ function connect(addr, opts) {
584
659
  DuplicateIdError,
585
660
  HistoryTruncatedError,
586
661
  InvalidQueryError,
662
+ InvalidUpdateError,
587
663
  Model,
588
664
  Ndb,
589
665
  NdbError,
590
666
  NotFoundError,
591
667
  Query,
668
+ TtlError,
592
669
  UnauthorizedError,
593
670
  UniqueViolationError,
594
- VersionConflictError,
595
671
  WatchLaggedError,
596
672
  connect,
597
673
  makeError
package/dist/index.d.cts CHANGED
@@ -18,6 +18,7 @@ type Fields = Record<string, FieldDef>;
18
18
  interface Schema<F extends Fields> {
19
19
  id?: Extract<keyof F, string>;
20
20
  time?: Extract<keyof F, string>;
21
+ expires?: Extract<keyof F, string>;
21
22
  fields: F;
22
23
  }
23
24
  type ValueOf<D extends FieldDef> = TypeMap[D['type']];
@@ -65,11 +66,43 @@ interface WriteResult {
65
66
  id: string;
66
67
  v: number;
67
68
  }
68
- interface WriteOptions {
69
- ifVersion?: number;
69
+ type Duration = number | string;
70
+ type Filter<F extends Fields> = Where<F> & {
71
+ _v?: number | Ops<number>;
72
+ $and?: Filter<F>[];
73
+ $or?: Filter<F>[];
74
+ $nor?: Filter<F>[];
75
+ };
76
+ interface UpdateOps<F extends Fields = Fields> {
77
+ $set?: Input<F> & Record<string, unknown>;
78
+ $unset?: Record<string, true | ''>;
79
+ $inc?: Record<string, number>;
80
+ $push?: Record<string, unknown>;
81
+ $pull?: Record<string, unknown>;
82
+ $min?: Record<string, number | string>;
83
+ $max?: Record<string, number | string>;
84
+ }
85
+ interface UpdateOptions {
86
+ many?: boolean;
87
+ upsert?: boolean;
88
+ expireIn?: Duration;
89
+ }
90
+ interface DeleteOptions {
91
+ many?: boolean;
92
+ }
93
+ interface UpdateResult<F extends Fields = Fields> {
94
+ matched: number;
95
+ modified: number;
96
+ doc?: Row<F>;
97
+ }
98
+ interface DeleteResult {
99
+ deleted: number;
70
100
  }
71
- interface UpdateOptions extends WriteOptions {
72
- unset?: string[];
101
+ interface InsertOptions {
102
+ expireIn?: Duration;
103
+ }
104
+ interface ExpireResult extends WriteResult {
105
+ expiresAt: Date;
73
106
  }
74
107
  interface AggregateOptions {
75
108
  limit?: number;
@@ -139,10 +172,15 @@ declare class Model<F extends Fields> {
139
172
  get idField(): string;
140
173
  path(op?: string): string;
141
174
  ready(): Promise<void>;
142
- insert(docs: Input<F> | Input<F>[]): Promise<WriteResult[]>;
143
- create(docs: Input<F> | Input<F>[]): Promise<WriteResult[]>;
144
- update(id: string, set: Input<F>, opts?: UpdateOptions): Promise<WriteResult>;
145
- delete(id: string, opts?: WriteOptions): Promise<void>;
175
+ insert(docs: Input<F> | Input<F>[], opts?: InsertOptions): Promise<WriteResult[]>;
176
+ create(docs: Input<F> | Input<F>[], opts?: InsertOptions): Promise<WriteResult[]>;
177
+ update(filter: Filter<F>, ops: UpdateOps<F>, opts?: UpdateOptions): Promise<UpdateResult<F>>;
178
+ delete(filter: Filter<F>, opts?: DeleteOptions): Promise<DeleteResult>;
179
+ expire(id: string, duration: Duration): Promise<ExpireResult>;
180
+ expireAt(id: string, at: Date): Promise<ExpireResult>;
181
+ persist(id: string): Promise<WriteResult>;
182
+ ttl(id: string): Promise<number | null>;
183
+ private setExpiry;
146
184
  find(id: string): Promise<Row<F> | null>;
147
185
  query(): Query<F>;
148
186
  where(w: Where<F>): Query<F>;
@@ -201,7 +239,7 @@ declare class Ndb {
201
239
  }
202
240
  declare function connect(addr?: string, opts?: ConnectOptions): Ndb;
203
241
 
204
- type ErrorCode = 'unauthorized' | 'not_found' | 'version_conflict' | 'duplicate_id' | 'unique_violation' | 'catalog_conflict' | 'catalog_invalid' | 'unknown_collection' | 'direct_collection' | 'invalid_request' | 'invalid_query' | 'invalid_pipeline' | 'group_limit_exceeded' | 'cursor_invalid' | 'cursor_mismatch' | 'cursor_expired' | 'request_too_large' | 'line_too_large' | 'not_ready' | 'closed' | 'watch_lagged' | 'invalid_position' | 'history_truncated' | 'http_status' | (string & {});
242
+ type ErrorCode = 'unauthorized' | 'not_found' | 'duplicate_id' | 'unique_violation' | 'catalog_conflict' | 'catalog_invalid' | 'unknown_collection' | 'direct_collection' | 'invalid_request' | 'invalid_query' | 'invalid_pipeline' | 'group_limit_exceeded' | 'cursor_invalid' | 'cursor_mismatch' | 'cursor_expired' | 'request_too_large' | 'line_too_large' | 'not_ready' | 'closed' | 'watch_lagged' | 'invalid_position' | 'history_truncated' | 'ttl_not_enabled' | 'ttl_derived' | 'unknown_operator' | 'no_update' | 'conflicting_update' | 'invalid_field' | 'unindexed_many' | 'empty_filter' | 'invalid_update' | 'http_status' | (string & {});
205
243
  declare class NdbError extends Error {
206
244
  readonly code: ErrorCode;
207
245
  readonly status: number;
@@ -211,8 +249,6 @@ declare class UnauthorizedError extends NdbError {
211
249
  }
212
250
  declare class NotFoundError extends NdbError {
213
251
  }
214
- declare class VersionConflictError extends NdbError {
215
- }
216
252
  declare class DuplicateIdError extends NdbError {
217
253
  }
218
254
  declare class UniqueViolationError extends NdbError {
@@ -225,7 +261,11 @@ declare class WatchLaggedError extends NdbError {
225
261
  }
226
262
  declare class HistoryTruncatedError extends NdbError {
227
263
  }
264
+ declare class InvalidUpdateError extends NdbError {
265
+ }
266
+ declare class TtlError extends NdbError {
267
+ }
228
268
  declare function makeError(code: ErrorCode, message: string, status?: number): NdbError;
229
269
 
230
- export { CursorError, DuplicateIdError, HistoryTruncatedError, InvalidQueryError, Model, Ndb, NdbError, NotFoundError, Query, UnauthorizedError, UniqueViolationError, VersionConflictError, WatchLaggedError, connect, makeError };
231
- export type { AggregateOptions, AggregateResult, ConnectOptions, Doc, ErrorCode, FieldDef, FieldType, Fields, Input, Ops, Row, Schema, SortSpec, TypeMap, UpdateOptions, WatchEvent, WatchOptions, Where, WriteOptions, WriteResult };
270
+ export { CursorError, DuplicateIdError, HistoryTruncatedError, InvalidQueryError, InvalidUpdateError, Model, Ndb, NdbError, NotFoundError, Query, TtlError, UnauthorizedError, UniqueViolationError, WatchLaggedError, connect, makeError };
271
+ export type { AggregateOptions, AggregateResult, ConnectOptions, DeleteOptions, DeleteResult, Doc, Duration, ErrorCode, ExpireResult, FieldDef, FieldType, Fields, Filter, Input, InsertOptions, Ops, Row, Schema, SortSpec, TypeMap, UpdateOps, UpdateOptions, UpdateResult, WatchEvent, WatchOptions, Where, WriteResult };
package/dist/index.d.ts CHANGED
@@ -18,6 +18,7 @@ type Fields = Record<string, FieldDef>;
18
18
  interface Schema<F extends Fields> {
19
19
  id?: Extract<keyof F, string>;
20
20
  time?: Extract<keyof F, string>;
21
+ expires?: Extract<keyof F, string>;
21
22
  fields: F;
22
23
  }
23
24
  type ValueOf<D extends FieldDef> = TypeMap[D['type']];
@@ -65,11 +66,43 @@ interface WriteResult {
65
66
  id: string;
66
67
  v: number;
67
68
  }
68
- interface WriteOptions {
69
- ifVersion?: number;
69
+ type Duration = number | string;
70
+ type Filter<F extends Fields> = Where<F> & {
71
+ _v?: number | Ops<number>;
72
+ $and?: Filter<F>[];
73
+ $or?: Filter<F>[];
74
+ $nor?: Filter<F>[];
75
+ };
76
+ interface UpdateOps<F extends Fields = Fields> {
77
+ $set?: Input<F> & Record<string, unknown>;
78
+ $unset?: Record<string, true | ''>;
79
+ $inc?: Record<string, number>;
80
+ $push?: Record<string, unknown>;
81
+ $pull?: Record<string, unknown>;
82
+ $min?: Record<string, number | string>;
83
+ $max?: Record<string, number | string>;
84
+ }
85
+ interface UpdateOptions {
86
+ many?: boolean;
87
+ upsert?: boolean;
88
+ expireIn?: Duration;
89
+ }
90
+ interface DeleteOptions {
91
+ many?: boolean;
92
+ }
93
+ interface UpdateResult<F extends Fields = Fields> {
94
+ matched: number;
95
+ modified: number;
96
+ doc?: Row<F>;
97
+ }
98
+ interface DeleteResult {
99
+ deleted: number;
70
100
  }
71
- interface UpdateOptions extends WriteOptions {
72
- unset?: string[];
101
+ interface InsertOptions {
102
+ expireIn?: Duration;
103
+ }
104
+ interface ExpireResult extends WriteResult {
105
+ expiresAt: Date;
73
106
  }
74
107
  interface AggregateOptions {
75
108
  limit?: number;
@@ -139,10 +172,15 @@ declare class Model<F extends Fields> {
139
172
  get idField(): string;
140
173
  path(op?: string): string;
141
174
  ready(): Promise<void>;
142
- insert(docs: Input<F> | Input<F>[]): Promise<WriteResult[]>;
143
- create(docs: Input<F> | Input<F>[]): Promise<WriteResult[]>;
144
- update(id: string, set: Input<F>, opts?: UpdateOptions): Promise<WriteResult>;
145
- delete(id: string, opts?: WriteOptions): Promise<void>;
175
+ insert(docs: Input<F> | Input<F>[], opts?: InsertOptions): Promise<WriteResult[]>;
176
+ create(docs: Input<F> | Input<F>[], opts?: InsertOptions): Promise<WriteResult[]>;
177
+ update(filter: Filter<F>, ops: UpdateOps<F>, opts?: UpdateOptions): Promise<UpdateResult<F>>;
178
+ delete(filter: Filter<F>, opts?: DeleteOptions): Promise<DeleteResult>;
179
+ expire(id: string, duration: Duration): Promise<ExpireResult>;
180
+ expireAt(id: string, at: Date): Promise<ExpireResult>;
181
+ persist(id: string): Promise<WriteResult>;
182
+ ttl(id: string): Promise<number | null>;
183
+ private setExpiry;
146
184
  find(id: string): Promise<Row<F> | null>;
147
185
  query(): Query<F>;
148
186
  where(w: Where<F>): Query<F>;
@@ -201,7 +239,7 @@ declare class Ndb {
201
239
  }
202
240
  declare function connect(addr?: string, opts?: ConnectOptions): Ndb;
203
241
 
204
- type ErrorCode = 'unauthorized' | 'not_found' | 'version_conflict' | 'duplicate_id' | 'unique_violation' | 'catalog_conflict' | 'catalog_invalid' | 'unknown_collection' | 'direct_collection' | 'invalid_request' | 'invalid_query' | 'invalid_pipeline' | 'group_limit_exceeded' | 'cursor_invalid' | 'cursor_mismatch' | 'cursor_expired' | 'request_too_large' | 'line_too_large' | 'not_ready' | 'closed' | 'watch_lagged' | 'invalid_position' | 'history_truncated' | 'http_status' | (string & {});
242
+ type ErrorCode = 'unauthorized' | 'not_found' | 'duplicate_id' | 'unique_violation' | 'catalog_conflict' | 'catalog_invalid' | 'unknown_collection' | 'direct_collection' | 'invalid_request' | 'invalid_query' | 'invalid_pipeline' | 'group_limit_exceeded' | 'cursor_invalid' | 'cursor_mismatch' | 'cursor_expired' | 'request_too_large' | 'line_too_large' | 'not_ready' | 'closed' | 'watch_lagged' | 'invalid_position' | 'history_truncated' | 'ttl_not_enabled' | 'ttl_derived' | 'unknown_operator' | 'no_update' | 'conflicting_update' | 'invalid_field' | 'unindexed_many' | 'empty_filter' | 'invalid_update' | 'http_status' | (string & {});
205
243
  declare class NdbError extends Error {
206
244
  readonly code: ErrorCode;
207
245
  readonly status: number;
@@ -211,8 +249,6 @@ declare class UnauthorizedError extends NdbError {
211
249
  }
212
250
  declare class NotFoundError extends NdbError {
213
251
  }
214
- declare class VersionConflictError extends NdbError {
215
- }
216
252
  declare class DuplicateIdError extends NdbError {
217
253
  }
218
254
  declare class UniqueViolationError extends NdbError {
@@ -225,7 +261,11 @@ declare class WatchLaggedError extends NdbError {
225
261
  }
226
262
  declare class HistoryTruncatedError extends NdbError {
227
263
  }
264
+ declare class InvalidUpdateError extends NdbError {
265
+ }
266
+ declare class TtlError extends NdbError {
267
+ }
228
268
  declare function makeError(code: ErrorCode, message: string, status?: number): NdbError;
229
269
 
230
- export { CursorError, DuplicateIdError, HistoryTruncatedError, InvalidQueryError, Model, Ndb, NdbError, NotFoundError, Query, UnauthorizedError, UniqueViolationError, VersionConflictError, WatchLaggedError, connect, makeError };
231
- export type { AggregateOptions, AggregateResult, ConnectOptions, Doc, ErrorCode, FieldDef, FieldType, Fields, Input, Ops, Row, Schema, SortSpec, TypeMap, UpdateOptions, WatchEvent, WatchOptions, Where, WriteOptions, WriteResult };
270
+ export { CursorError, DuplicateIdError, HistoryTruncatedError, InvalidQueryError, InvalidUpdateError, Model, Ndb, NdbError, NotFoundError, Query, TtlError, UnauthorizedError, UniqueViolationError, WatchLaggedError, connect, makeError };
271
+ export type { AggregateOptions, AggregateResult, ConnectOptions, DeleteOptions, DeleteResult, Doc, Duration, ErrorCode, ExpireResult, FieldDef, FieldType, Fields, Filter, Input, InsertOptions, Ops, Row, Schema, SortSpec, TypeMap, UpdateOps, UpdateOptions, UpdateResult, WatchEvent, WatchOptions, Where, WriteResult };
package/dist/index.js CHANGED
@@ -13,8 +13,6 @@ var UnauthorizedError = class extends NdbError {
13
13
  };
14
14
  var NotFoundError = class extends NdbError {
15
15
  };
16
- var VersionConflictError = class extends NdbError {
17
- };
18
16
  var DuplicateIdError = class extends NdbError {
19
17
  };
20
18
  var UniqueViolationError = class extends NdbError {
@@ -27,11 +25,14 @@ var WatchLaggedError = class extends NdbError {
27
25
  };
28
26
  var HistoryTruncatedError = class extends NdbError {
29
27
  };
28
+ var InvalidUpdateError = class extends NdbError {
29
+ };
30
+ var TtlError = class extends NdbError {
31
+ };
30
32
  var subclasses = {
31
33
  unauthorized: UnauthorizedError,
32
34
  not_found: NotFoundError,
33
35
  unknown_collection: NotFoundError,
34
- version_conflict: VersionConflictError,
35
36
  duplicate_id: DuplicateIdError,
36
37
  unique_violation: UniqueViolationError,
37
38
  invalid_query: InvalidQueryError,
@@ -40,7 +41,15 @@ var subclasses = {
40
41
  cursor_mismatch: CursorError,
41
42
  cursor_expired: CursorError,
42
43
  watch_lagged: WatchLaggedError,
43
- history_truncated: HistoryTruncatedError
44
+ history_truncated: HistoryTruncatedError,
45
+ ttl_not_enabled: TtlError,
46
+ ttl_derived: TtlError,
47
+ unknown_operator: InvalidUpdateError,
48
+ no_update: InvalidUpdateError,
49
+ conflicting_update: InvalidUpdateError,
50
+ invalid_field: InvalidUpdateError,
51
+ unindexed_many: InvalidUpdateError,
52
+ empty_filter: InvalidUpdateError
44
53
  };
45
54
  function makeError(code, message, status = 0) {
46
55
  const Cls = subclasses[code] ?? NdbError;
@@ -234,25 +243,71 @@ var Model = class {
234
243
  });
235
244
  return this.ensured;
236
245
  }
237
- async insert(docs) {
246
+ async insert(docs, opts = {}) {
238
247
  await this.ready();
239
248
  const list = Array.isArray(docs) ? docs : [docs];
240
249
  const out = await this.db.transport.request("POST", this.path("insert"), {
241
- insert: list
250
+ insert: list,
251
+ expire_in: opts.expireIn
242
252
  });
243
253
  return out.results;
244
254
  }
245
- create(docs) {
246
- return this.insert(docs);
255
+ create(docs, opts = {}) {
256
+ return this.insert(docs, opts);
257
+ }
258
+ // Atomic update by filter. The filter and the operators are sent as one
259
+ // body; the daemon matches and writes in one step on the newest state, so
260
+ // concurrent updates never interleave. A version guard is a filter:
261
+ // { call_id: 'c1', _v: 7 }. Matching nothing is not an error: matched is 0.
262
+ async update(filter, ops, opts = {}) {
263
+ await this.ready();
264
+ const body = mergeUpdate(filter, ops);
265
+ const query = writeQuery({ many: opts.many, upsert: opts.upsert, expire_in: opts.expireIn });
266
+ const out = await this.db.transport.request(
267
+ "POST",
268
+ this.path("update") + query,
269
+ body
270
+ );
271
+ return { matched: out.matched, modified: out.modified, doc: out.doc && this.decode(out.doc) };
247
272
  }
248
- async update(id, set, opts = {}) {
273
+ // Deletes the documents that the filter matches: one, or every match with
274
+ // many (same rule as update).
275
+ async delete(filter, opts = {}) {
249
276
  await this.ready();
250
- const update = { id, set, unset: opts.unset, if_version: opts.ifVersion };
251
- return this.db.transport.request("POST", this.path("update"), { update });
277
+ return this.db.transport.request("POST", this.path("delete") + writeQuery({ many: opts.many }), toWhere(filter));
252
278
  }
253
- async delete(id, opts = {}) {
279
+ // Makes a document expire, like EXPIRE in Redis: seconds or "30m". It throws
280
+ // NotFoundError for a missing or expired document, and TtlError when the
281
+ // schema has no expires field.
282
+ expire(id, duration) {
283
+ return this.setExpiry({ id, in: duration });
284
+ }
285
+ expireAt(id, at) {
286
+ return this.setExpiry({ id, at: at.toISOString() });
287
+ }
288
+ // Removes the expiry of a document (PERSIST in Redis).
289
+ async persist(id) {
254
290
  await this.ready();
255
- await this.db.transport.request("POST", this.path("delete"), { delete: { id, if_version: opts.ifVersion } });
291
+ return this.db.transport.request("POST", this.path("persist"), { id });
292
+ }
293
+ // The seconds left (rounded up), -1 when the document never expires, null
294
+ // when it is missing or expired.
295
+ async ttl(id) {
296
+ await this.ready();
297
+ try {
298
+ const out = await this.db.transport.request("GET", `${this.path("ttl")}/${encodeURIComponent(id)}`);
299
+ return out.ttl;
300
+ } catch (err) {
301
+ if (err instanceof NotFoundError) {
302
+ return null;
303
+ }
304
+ throw err;
305
+ }
306
+ }
307
+ async setExpiry(body) {
308
+ await this.ready();
309
+ const out = await this.db.transport.request("POST", this.path("expire"), body);
310
+ return { id: out.id, v: out.v, expiresAt: new Date(out.expires_at) };
256
311
  }
257
312
  find(id) {
258
313
  return this.query().where({ [this.idField]: id }).first();
@@ -334,11 +389,30 @@ var Model = class {
334
389
  return {
335
390
  id: this.schema.id,
336
391
  time: this.schema.time,
392
+ expires_field: this.schema.expires,
337
393
  indexes: entries.filter(([, d]) => d.index).map(([n]) => n),
338
394
  unique: entries.filter(([, d]) => d.unique).map(([n]) => n)
339
395
  };
340
396
  }
341
397
  };
398
+ function writeQuery(params) {
399
+ const q = new URLSearchParams();
400
+ for (const [name, value] of Object.entries(params)) {
401
+ if (value !== void 0 && value !== false) {
402
+ q.set(name, String(value));
403
+ }
404
+ }
405
+ const text = q.toString();
406
+ return text === "" ? "" : `?${text}`;
407
+ }
408
+ function mergeUpdate(filter, ops) {
409
+ for (const key of Object.keys(ops)) {
410
+ if (!key.startsWith("$")) {
411
+ throw new InvalidUpdateError("invalid_update", `update operator "${key}" must start with $ (for example "$set")`);
412
+ }
413
+ }
414
+ return { ...toWhere(filter), ...ops };
415
+ }
342
416
 
343
417
  // src/transport.ts
344
418
  import { Pool } from "undici";
@@ -543,14 +617,15 @@ export {
543
617
  DuplicateIdError,
544
618
  HistoryTruncatedError,
545
619
  InvalidQueryError,
620
+ InvalidUpdateError,
546
621
  Model,
547
622
  Ndb,
548
623
  NdbError,
549
624
  NotFoundError,
550
625
  Query,
626
+ TtlError,
551
627
  UnauthorizedError,
552
628
  UniqueViolationError,
553
- VersionConflictError,
554
629
  WatchLaggedError,
555
630
  connect,
556
631
  makeError
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thadeu/ndb",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "TypeScript client for the ndb daemon",
5
5
  "keywords": [
6
6
  "ndb",