@thadeu/ndb 0.1.2 → 0.2.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/README.md CHANGED
@@ -51,10 +51,8 @@ await ndb.close()
51
51
 
52
52
  ```ts
53
53
  const calls = ndb.model('calls', {
54
- id: 'call_id',
55
54
  time: 'at',
56
55
  fields: {
57
- call_id: { type: 'string' },
58
56
  status: { type: 'string', index: true },
59
57
  caller: { type: 'string', unique: true },
60
58
  duration: { type: 'number', optional: true },
@@ -66,10 +64,10 @@ const calls = ndb.model('calls', {
66
64
 
67
65
  - Field types: `string`, `number`, `boolean`, `date` (a `Date`), `json` (any value).
68
66
  - Field options: `index`, `unique`, `optional`.
69
- - `id` is the id field. Without it the id is `_id`, made by the daemon.
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`.
70
68
  - `time` is the collection time field. Used by zone maps and `$dateTrunc`.
71
69
  - 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.
70
+ - 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.
73
71
  - `expires` is the `date` field that holds when a document expires. It turns on `expireIn`, `expire`, `persist` and `ttl` (below).
74
72
  - The client has no `ttl` catalog option and no blob API. Use the HTTP API for those.
75
73
 
@@ -78,49 +76,51 @@ const calls = ndb.model('calls', {
78
76
  ```ts
79
77
  // Insert one or many. All documents go in one transaction.
80
78
  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() },
79
+ { _id: 'c1', status: 'answered', caller: 'a', duration: 30, at: new Date() },
80
+ { _id: 'c2', status: 'failed', caller: 'b', at: new Date() },
83
81
  ])
84
- // results: [{ id: 'c1', v: 1 }, ...]
82
+ // results: [{ _id: 'c1', v: 1 }, ...]
85
83
 
86
- // Find by id. Returns null when missing.
87
- const row = await calls.find('c1')
84
+ // Get by _id (GET /v1/c/calls/c1). Returns null when missing.
85
+ const row = await calls.get('c1')
88
86
 
89
87
  // Atomic update by filter: filter first, then the write operators.
90
88
  // _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 } })
89
+ const res = await calls.update({ _id: 'c1', _v: row!._v }, { $set: { status: 'done' }, $unset: { duration: true } })
92
90
  // res: { matched, modified, doc } (doc is the new row with _v; absent for many)
93
91
 
92
+ // A string is an _id (POST /{_id}/update). The same guard rules apply.
93
+ await calls.update('c1', { $inc: { duration: 1 } })
94
+
94
95
  // A guarded decrement: exactly one of two racing calls wins.
95
- await stock.update({ sku: 'a', qty: { $gte: 1 } }, { $inc: { qty: -1 } })
96
+ await stock.update({ _id: 'a', qty: { $gte: 1 } }, { $inc: { qty: -1 } })
96
97
 
97
- // Every match (the filter needs the id or an indexed field), or create it.
98
+ // Every match (the filter needs _id or an indexed field), or create it.
98
99
  await calls.update({ status: 'failed' }, { $set: { status: 'retry' } }, { many: true })
99
- await calls.update({ call_id: 'c9' }, { $inc: { duration: 1 } }, { upsert: true })
100
+ await calls.update('c9', { $inc: { duration: 1 } }, { upsert: true })
100
101
 
101
- // Delete by filter: { deleted }.
102
- await calls.delete({ call_id: 'c2' })
102
+ // Delete by _id or by filter: { deleted }.
103
+ await calls.delete('c2')
103
104
  await calls.delete({ status: 'failed' }, { many: true })
104
105
  ```
105
106
 
106
- - Operators: `$set`, `$unset`, `$inc`, `$push` (with `{ $each: [...] }`), `$pull`, `$min`, `$max`. Field names can be dot paths.
107
+ - 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.
107
108
  - 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
+ - `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`).
109
110
  - 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
 
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
+ 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`.
112
113
 
113
114
  ### Expiry (TTL like Redis)
114
115
 
115
116
  ```ts
116
117
  const sessions = db.model('sessions', {
117
- id: 'sid',
118
118
  expires: 'exp',
119
- fields: { sid: { type: 'string' }, exp: { type: 'date', optional: true } },
119
+ fields: { exp: { type: 'date', optional: true } },
120
120
  })
121
121
 
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' })
122
+ await sessions.insert({ _id: 's1' }, { expireIn: '1h' }) // or 3600; all docs of the call
123
+ await sessions.update('s1', { $set: { user: 'u' } }, { expireIn: '30m' })
124
124
 
125
125
  const { v, expiresAt } = await sessions.expire('s1', '30m') // EXPIRE
126
126
  await sessions.expireAt('s1', new Date(Date.now() + 3_600_000))
@@ -142,8 +142,8 @@ const rows = await calls
142
142
  .where({ caller: { $in: ['a', 'b'] } })
143
143
  .not({ caller: 'zzz' })
144
144
  .or({ duration: { $lt: 5 } }, { 'meta.agent.id': 7 })
145
- .sort({ at: -1, call_id: 1 })
146
- .fields('call_id', 'status')
145
+ .sort({ at: -1, _id: 1 })
146
+ .fields('_id', 'status')
147
147
  .limit(50)
148
148
  .skip(100)
149
149
  ```
@@ -168,11 +168,11 @@ const total = await calls.count()
168
168
  Offset pages get slower as `skip` grows. Use a cursor for deep pages.
169
169
 
170
170
  ```ts
171
- const q = calls.where({ status: 'answered' }).sort({ call_id: 1 }).limit(100)
171
+ const q = calls.where({ status: 'answered' }).sort({ _id: 1 }).limit(100)
172
172
  const page = await q
173
173
  const token = q.nextCursor // '' on the last page
174
174
 
175
- const next = await calls.where({ status: 'answered' }).sort({ call_id: 1 }).limit(100).after(token)
175
+ const next = await calls.where({ status: 'answered' }).sort({ _id: 1 }).limit(100).after(token)
176
176
  ```
177
177
 
178
178
  A token works only with the same `where` and `sort`. Another query shape throws `CursorError`.
@@ -180,8 +180,8 @@ A token works only with the same `where` and `sort`. Another query shape throws
180
180
  To walk every row, use `iterate()`. It follows the cursor, with `limit` as the batch size:
181
181
 
182
182
  ```ts
183
- for await (const row of calls.where({ status: 'answered' }).sort({ call_id: 1 }).limit(500).iterate()) {
184
- console.log(row.call_id)
183
+ for await (const row of calls.where({ status: 'answered' }).sort({ _id: 1 }).limit(500).iterate()) {
184
+ console.log(row._id)
185
185
  }
186
186
  ```
187
187
 
@@ -236,7 +236,7 @@ const controller = new AbortController()
236
236
 
237
237
  try {
238
238
  for await (const ev of calls.watch('relay', { where: { status: 'answered' }, signal: controller.signal })) {
239
- // ev: { consumer, pos, op: 'put' | 'del', id, v, doc }
239
+ // ev: { consumer, pos, op: 'put' | 'del', _id, v, doc }
240
240
  await handle(ev)
241
241
 
242
242
  await calls.ack(ev)
@@ -280,7 +280,7 @@ Daemon errors are `NdbError` or a subclass. They have `code` (the daemon code) a
280
280
  import { NdbError } from '@thadeu/ndb'
281
281
 
282
282
  try {
283
- await calls.find('c1')
283
+ await calls.get('c1')
284
284
  } catch (err) {
285
285
  if (err instanceof NdbError && err.code === 'not_ready') {
286
286
  // The daemon is still recovering, and readyTimeout ran out.
package/dist/index.cjs CHANGED
@@ -35,7 +35,8 @@ __export(index_exports, {
35
35
  UniqueViolationError: () => UniqueViolationError,
36
36
  WatchLaggedError: () => WatchLaggedError,
37
37
  connect: () => connect,
38
- makeError: () => makeError
38
+ makeError: () => makeError,
39
+ newId: () => newId
39
40
  });
40
41
  module.exports = __toCommonJS(index_exports);
41
42
 
@@ -255,7 +256,7 @@ async function* watchEvents(lines2, consumer) {
255
256
  if (line.error) {
256
257
  throw makeError(line.error.code, line.error.message);
257
258
  }
258
- yield { consumer, pos: line.pos, op: line.op, id: line.id, v: line.v, doc: line.doc };
259
+ yield { consumer, pos: line.pos, op: line.op, _id: line._id, v: line.v, doc: line.doc };
259
260
  }
260
261
  }
261
262
 
@@ -270,9 +271,6 @@ var Model = class {
270
271
  name;
271
272
  schema;
272
273
  ensured;
273
- get idField() {
274
- return this.schema.id ?? "_id";
275
- }
276
274
  path(op) {
277
275
  const base = `/v1/c/${encodeURIComponent(this.name)}`;
278
276
  return op ? `${base}/${op}` : base;
@@ -296,47 +294,55 @@ var Model = class {
296
294
  create(docs, opts = {}) {
297
295
  return this.insert(docs, opts);
298
296
  }
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
297
+ // Atomic update. A string is an _id (POST /{_id}/update); an object is a
298
+ // filter (POST /update). The filter and the operators are sent as one body;
299
+ // the daemon matches and writes in one step on the newest state, so
301
300
  // 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 = {}) {
301
+ // { _id: 'c1', _v: 7 }. Matching nothing is not an error: matched is 0.
302
+ // many does not apply to an _id.
303
+ async update(idOrFilter, ops, opts = {}) {
304
304
  await this.ready();
305
- const body = mergeUpdate(filter, ops);
306
- const query = writeQuery({ many: opts.many, upsert: opts.upsert, expire_in: opts.expireIn });
305
+ const byId = typeof idOrFilter === "string";
306
+ const body = mergeUpdate(byId ? {} : idOrFilter, ops);
307
+ const query = writeQuery({ many: byId ? void 0 : opts.many, upsert: opts.upsert, expire_in: opts.expireIn });
308
+ const op = byId ? `${encodeURIComponent(idOrFilter)}/update` : "update";
307
309
  const out = await this.db.transport.request(
308
310
  "POST",
309
- this.path("update") + query,
311
+ this.path(op) + query,
310
312
  body
311
313
  );
312
314
  return { matched: out.matched, modified: out.modified, doc: out.doc && this.decode(out.doc) };
313
315
  }
314
- // Deletes the documents that the filter matches: one, or every match with
315
- // many (same rule as update).
316
- async delete(filter, opts = {}) {
316
+ // Deletes by _id (a string), or the documents that the filter matches: one,
317
+ // or every match with many (same rule as update).
318
+ async delete(idOrFilter, opts = {}) {
317
319
  await this.ready();
320
+ if (typeof idOrFilter === "string") {
321
+ return this.db.transport.request("DELETE", this.path(encodeURIComponent(idOrFilter)));
322
+ }
323
+ const filter = idOrFilter;
318
324
  return this.db.transport.request("POST", this.path("delete") + writeQuery({ many: opts.many }), toWhere(filter));
319
325
  }
320
326
  // Makes a document expire, like EXPIRE in Redis: seconds or "30m". It throws
321
327
  // NotFoundError for a missing or expired document, and TtlError when the
322
328
  // schema has no expires field.
323
329
  expire(id, duration) {
324
- return this.setExpiry({ id, in: duration });
330
+ return this.setExpiry({ _id: id, in: duration });
325
331
  }
326
332
  expireAt(id, at) {
327
- return this.setExpiry({ id, at: at.toISOString() });
333
+ return this.setExpiry({ _id: id, at: at.toISOString() });
328
334
  }
329
335
  // Removes the expiry of a document (PERSIST in Redis).
330
336
  async persist(id) {
331
337
  await this.ready();
332
- return this.db.transport.request("POST", this.path("persist"), { id });
338
+ return this.db.transport.request("POST", this.path("persist"), { _id: id });
333
339
  }
334
340
  // The seconds left (rounded up), -1 when the document never expires, null
335
341
  // when it is missing or expired.
336
342
  async ttl(id) {
337
343
  await this.ready();
338
344
  try {
339
- const out = await this.db.transport.request("GET", `${this.path("ttl")}/${encodeURIComponent(id)}`);
345
+ const out = await this.db.transport.request("GET", this.path(`${encodeURIComponent(id)}/ttl`));
340
346
  return out.ttl;
341
347
  } catch (err) {
342
348
  if (err instanceof NotFoundError) {
@@ -348,10 +354,20 @@ var Model = class {
348
354
  async setExpiry(body) {
349
355
  await this.ready();
350
356
  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) };
357
+ return { _id: out._id, v: out.v, expiresAt: new Date(out.expires_at) };
352
358
  }
353
- find(id) {
354
- return this.query().where({ [this.idField]: id }).first();
359
+ // The document with this _id, or null.
360
+ async get(id) {
361
+ await this.ready();
362
+ try {
363
+ const doc = await this.db.transport.request("GET", this.path(encodeURIComponent(id)));
364
+ return this.decode(doc);
365
+ } catch (err) {
366
+ if (err instanceof NotFoundError) {
367
+ return null;
368
+ }
369
+ throw err;
370
+ }
355
371
  }
356
372
  query() {
357
373
  return new Query(this);
@@ -428,7 +444,6 @@ var Model = class {
428
444
  catalog() {
429
445
  const entries = Object.entries(this.schema.fields);
430
446
  return {
431
- id: this.schema.id,
432
447
  time: this.schema.time,
433
448
  expires_field: this.schema.expires,
434
449
  indexes: entries.filter(([, d]) => d.index).map(([n]) => n),
@@ -653,6 +668,38 @@ var Ndb = class {
653
668
  function connect(addr, opts) {
654
669
  return new Ndb(addr, opts);
655
670
  }
671
+
672
+ // src/id.ts
673
+ var lastMs = 0;
674
+ var counter = 0;
675
+ function newId() {
676
+ const bytes = new Uint8Array(16);
677
+ crypto.getRandomValues(bytes);
678
+ const now = Date.now();
679
+ if (now > lastMs) {
680
+ lastMs = now;
681
+ counter = seedCounter();
682
+ } else if (counter === 4095) {
683
+ lastMs += 1;
684
+ counter = seedCounter();
685
+ } else {
686
+ counter += 1;
687
+ }
688
+ const ms = BigInt(lastMs);
689
+ for (let i = 0; i < 6; i++) {
690
+ bytes[i] = Number(ms >> BigInt(8 * (5 - i)) & 0xffn);
691
+ }
692
+ bytes[6] = 112 | counter >> 8;
693
+ bytes[7] = counter & 255;
694
+ bytes[8] = 128 | bytes[8] & 63;
695
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
696
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
697
+ }
698
+ function seedCounter() {
699
+ const r = new Uint16Array(1);
700
+ crypto.getRandomValues(r);
701
+ return r[0] & 2047;
702
+ }
656
703
  // Annotate the CommonJS export names for ESM import in node:
657
704
  0 && (module.exports = {
658
705
  CursorError,
@@ -670,5 +717,6 @@ function connect(addr, opts) {
670
717
  UniqueViolationError,
671
718
  WatchLaggedError,
672
719
  connect,
673
- makeError
720
+ makeError,
721
+ newId
674
722
  });
package/dist/index.d.cts CHANGED
@@ -16,7 +16,6 @@ interface FieldDef {
16
16
  }
17
17
  type Fields = Record<string, FieldDef>;
18
18
  interface Schema<F extends Fields> {
19
- id?: Extract<keyof F, string>;
20
19
  time?: Extract<keyof F, string>;
21
20
  expires?: Extract<keyof F, string>;
22
21
  fields: F;
@@ -34,8 +33,11 @@ type Doc<F extends Fields> = {
34
33
  };
35
34
  type Input<F extends Fields> = {
36
35
  [K in keyof F]?: ValueOf<F[K]>;
36
+ } & {
37
+ _id?: string;
37
38
  };
38
39
  type Row<F extends Fields> = Doc<F> & {
40
+ _id: string;
39
41
  _v: number;
40
42
  };
41
43
  interface Ops<V> {
@@ -58,12 +60,14 @@ type JsonKeys<F extends Fields> = Extract<{
58
60
  }[keyof F], string>;
59
61
  type Where<F extends Fields> = {
60
62
  [K in keyof F]?: Operand<ValueOf<F[K]>> | Ops<Operand<ValueOf<F[K]>>> | RegexOperand<ValueOf<F[K]>>;
63
+ } & {
64
+ _id?: string | Ops<string>;
61
65
  } & {
62
66
  [P in `${JsonKeys<F>}.${string}`]?: unknown;
63
67
  };
64
- type SortSpec<F extends Fields> = Partial<Record<Extract<keyof F, string>, 1 | -1>>;
68
+ type SortSpec<F extends Fields> = Partial<Record<Extract<keyof F, string> | '_id', 1 | -1>>;
65
69
  interface WriteResult {
66
- id: string;
70
+ _id: string;
67
71
  v: number;
68
72
  }
69
73
  type Duration = number | string;
@@ -75,6 +79,7 @@ type Filter<F extends Fields> = Where<F> & {
75
79
  };
76
80
  interface UpdateOps<F extends Fields = Fields> {
77
81
  $set?: Input<F> & Record<string, unknown>;
82
+ $setOnInsert?: Input<F> & Record<string, unknown>;
78
83
  $unset?: Record<string, true | ''>;
79
84
  $inc?: Record<string, number>;
80
85
  $push?: Record<string, unknown>;
@@ -125,7 +130,7 @@ interface WatchEvent<R> {
125
130
  consumer: string;
126
131
  pos: string;
127
132
  op: 'put' | 'del';
128
- id: string;
133
+ _id: string;
129
134
  v: number;
130
135
  doc: R | undefined;
131
136
  }
@@ -169,19 +174,18 @@ declare class Model<F extends Fields> {
169
174
  readonly schema: Schema<F>;
170
175
  private ensured?;
171
176
  constructor(db: Ndb, name: string, schema: Schema<F>);
172
- get idField(): string;
173
177
  path(op?: string): string;
174
178
  ready(): Promise<void>;
175
179
  insert(docs: Input<F> | Input<F>[], opts?: InsertOptions): Promise<WriteResult[]>;
176
180
  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>;
181
+ update(idOrFilter: string | Filter<F>, ops: UpdateOps<F>, opts?: UpdateOptions): Promise<UpdateResult<F>>;
182
+ delete(idOrFilter: string | Filter<F>, opts?: DeleteOptions): Promise<DeleteResult>;
179
183
  expire(id: string, duration: Duration): Promise<ExpireResult>;
180
184
  expireAt(id: string, at: Date): Promise<ExpireResult>;
181
185
  persist(id: string): Promise<WriteResult>;
182
186
  ttl(id: string): Promise<number | null>;
183
187
  private setExpiry;
184
- find(id: string): Promise<Row<F> | null>;
188
+ get(id: string): Promise<Row<F> | null>;
185
189
  query(): Query<F>;
186
190
  where(w: Where<F>): Query<F>;
187
191
  not(w: Where<F>): Query<F>;
@@ -239,6 +243,8 @@ declare class Ndb {
239
243
  }
240
244
  declare function connect(addr?: string, opts?: ConnectOptions): Ndb;
241
245
 
246
+ declare function newId(): string;
247
+
242
248
  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 & {});
243
249
  declare class NdbError extends Error {
244
250
  readonly code: ErrorCode;
@@ -267,5 +273,5 @@ declare class TtlError extends NdbError {
267
273
  }
268
274
  declare function makeError(code: ErrorCode, message: string, status?: number): NdbError;
269
275
 
270
- export { CursorError, DuplicateIdError, HistoryTruncatedError, InvalidQueryError, InvalidUpdateError, Model, Ndb, NdbError, NotFoundError, Query, TtlError, UnauthorizedError, UniqueViolationError, WatchLaggedError, connect, makeError };
276
+ export { CursorError, DuplicateIdError, HistoryTruncatedError, InvalidQueryError, InvalidUpdateError, Model, Ndb, NdbError, NotFoundError, Query, TtlError, UnauthorizedError, UniqueViolationError, WatchLaggedError, connect, makeError, newId };
271
277
  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
@@ -16,7 +16,6 @@ interface FieldDef {
16
16
  }
17
17
  type Fields = Record<string, FieldDef>;
18
18
  interface Schema<F extends Fields> {
19
- id?: Extract<keyof F, string>;
20
19
  time?: Extract<keyof F, string>;
21
20
  expires?: Extract<keyof F, string>;
22
21
  fields: F;
@@ -34,8 +33,11 @@ type Doc<F extends Fields> = {
34
33
  };
35
34
  type Input<F extends Fields> = {
36
35
  [K in keyof F]?: ValueOf<F[K]>;
36
+ } & {
37
+ _id?: string;
37
38
  };
38
39
  type Row<F extends Fields> = Doc<F> & {
40
+ _id: string;
39
41
  _v: number;
40
42
  };
41
43
  interface Ops<V> {
@@ -58,12 +60,14 @@ type JsonKeys<F extends Fields> = Extract<{
58
60
  }[keyof F], string>;
59
61
  type Where<F extends Fields> = {
60
62
  [K in keyof F]?: Operand<ValueOf<F[K]>> | Ops<Operand<ValueOf<F[K]>>> | RegexOperand<ValueOf<F[K]>>;
63
+ } & {
64
+ _id?: string | Ops<string>;
61
65
  } & {
62
66
  [P in `${JsonKeys<F>}.${string}`]?: unknown;
63
67
  };
64
- type SortSpec<F extends Fields> = Partial<Record<Extract<keyof F, string>, 1 | -1>>;
68
+ type SortSpec<F extends Fields> = Partial<Record<Extract<keyof F, string> | '_id', 1 | -1>>;
65
69
  interface WriteResult {
66
- id: string;
70
+ _id: string;
67
71
  v: number;
68
72
  }
69
73
  type Duration = number | string;
@@ -75,6 +79,7 @@ type Filter<F extends Fields> = Where<F> & {
75
79
  };
76
80
  interface UpdateOps<F extends Fields = Fields> {
77
81
  $set?: Input<F> & Record<string, unknown>;
82
+ $setOnInsert?: Input<F> & Record<string, unknown>;
78
83
  $unset?: Record<string, true | ''>;
79
84
  $inc?: Record<string, number>;
80
85
  $push?: Record<string, unknown>;
@@ -125,7 +130,7 @@ interface WatchEvent<R> {
125
130
  consumer: string;
126
131
  pos: string;
127
132
  op: 'put' | 'del';
128
- id: string;
133
+ _id: string;
129
134
  v: number;
130
135
  doc: R | undefined;
131
136
  }
@@ -169,19 +174,18 @@ declare class Model<F extends Fields> {
169
174
  readonly schema: Schema<F>;
170
175
  private ensured?;
171
176
  constructor(db: Ndb, name: string, schema: Schema<F>);
172
- get idField(): string;
173
177
  path(op?: string): string;
174
178
  ready(): Promise<void>;
175
179
  insert(docs: Input<F> | Input<F>[], opts?: InsertOptions): Promise<WriteResult[]>;
176
180
  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>;
181
+ update(idOrFilter: string | Filter<F>, ops: UpdateOps<F>, opts?: UpdateOptions): Promise<UpdateResult<F>>;
182
+ delete(idOrFilter: string | Filter<F>, opts?: DeleteOptions): Promise<DeleteResult>;
179
183
  expire(id: string, duration: Duration): Promise<ExpireResult>;
180
184
  expireAt(id: string, at: Date): Promise<ExpireResult>;
181
185
  persist(id: string): Promise<WriteResult>;
182
186
  ttl(id: string): Promise<number | null>;
183
187
  private setExpiry;
184
- find(id: string): Promise<Row<F> | null>;
188
+ get(id: string): Promise<Row<F> | null>;
185
189
  query(): Query<F>;
186
190
  where(w: Where<F>): Query<F>;
187
191
  not(w: Where<F>): Query<F>;
@@ -239,6 +243,8 @@ declare class Ndb {
239
243
  }
240
244
  declare function connect(addr?: string, opts?: ConnectOptions): Ndb;
241
245
 
246
+ declare function newId(): string;
247
+
242
248
  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 & {});
243
249
  declare class NdbError extends Error {
244
250
  readonly code: ErrorCode;
@@ -267,5 +273,5 @@ declare class TtlError extends NdbError {
267
273
  }
268
274
  declare function makeError(code: ErrorCode, message: string, status?: number): NdbError;
269
275
 
270
- export { CursorError, DuplicateIdError, HistoryTruncatedError, InvalidQueryError, InvalidUpdateError, Model, Ndb, NdbError, NotFoundError, Query, TtlError, UnauthorizedError, UniqueViolationError, WatchLaggedError, connect, makeError };
276
+ export { CursorError, DuplicateIdError, HistoryTruncatedError, InvalidQueryError, InvalidUpdateError, Model, Ndb, NdbError, NotFoundError, Query, TtlError, UnauthorizedError, UniqueViolationError, WatchLaggedError, connect, makeError, newId };
271
277
  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
@@ -214,7 +214,7 @@ async function* watchEvents(lines2, consumer) {
214
214
  if (line.error) {
215
215
  throw makeError(line.error.code, line.error.message);
216
216
  }
217
- yield { consumer, pos: line.pos, op: line.op, id: line.id, v: line.v, doc: line.doc };
217
+ yield { consumer, pos: line.pos, op: line.op, _id: line._id, v: line.v, doc: line.doc };
218
218
  }
219
219
  }
220
220
 
@@ -229,9 +229,6 @@ var Model = class {
229
229
  name;
230
230
  schema;
231
231
  ensured;
232
- get idField() {
233
- return this.schema.id ?? "_id";
234
- }
235
232
  path(op) {
236
233
  const base = `/v1/c/${encodeURIComponent(this.name)}`;
237
234
  return op ? `${base}/${op}` : base;
@@ -255,47 +252,55 @@ var Model = class {
255
252
  create(docs, opts = {}) {
256
253
  return this.insert(docs, opts);
257
254
  }
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
255
+ // Atomic update. A string is an _id (POST /{_id}/update); an object is a
256
+ // filter (POST /update). The filter and the operators are sent as one body;
257
+ // the daemon matches and writes in one step on the newest state, so
260
258
  // 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 = {}) {
259
+ // { _id: 'c1', _v: 7 }. Matching nothing is not an error: matched is 0.
260
+ // many does not apply to an _id.
261
+ async update(idOrFilter, ops, opts = {}) {
263
262
  await this.ready();
264
- const body = mergeUpdate(filter, ops);
265
- const query = writeQuery({ many: opts.many, upsert: opts.upsert, expire_in: opts.expireIn });
263
+ const byId = typeof idOrFilter === "string";
264
+ const body = mergeUpdate(byId ? {} : idOrFilter, ops);
265
+ const query = writeQuery({ many: byId ? void 0 : opts.many, upsert: opts.upsert, expire_in: opts.expireIn });
266
+ const op = byId ? `${encodeURIComponent(idOrFilter)}/update` : "update";
266
267
  const out = await this.db.transport.request(
267
268
  "POST",
268
- this.path("update") + query,
269
+ this.path(op) + query,
269
270
  body
270
271
  );
271
272
  return { matched: out.matched, modified: out.modified, doc: out.doc && this.decode(out.doc) };
272
273
  }
273
- // Deletes the documents that the filter matches: one, or every match with
274
- // many (same rule as update).
275
- async delete(filter, opts = {}) {
274
+ // Deletes by _id (a string), or the documents that the filter matches: one,
275
+ // or every match with many (same rule as update).
276
+ async delete(idOrFilter, opts = {}) {
276
277
  await this.ready();
278
+ if (typeof idOrFilter === "string") {
279
+ return this.db.transport.request("DELETE", this.path(encodeURIComponent(idOrFilter)));
280
+ }
281
+ const filter = idOrFilter;
277
282
  return this.db.transport.request("POST", this.path("delete") + writeQuery({ many: opts.many }), toWhere(filter));
278
283
  }
279
284
  // Makes a document expire, like EXPIRE in Redis: seconds or "30m". It throws
280
285
  // NotFoundError for a missing or expired document, and TtlError when the
281
286
  // schema has no expires field.
282
287
  expire(id, duration) {
283
- return this.setExpiry({ id, in: duration });
288
+ return this.setExpiry({ _id: id, in: duration });
284
289
  }
285
290
  expireAt(id, at) {
286
- return this.setExpiry({ id, at: at.toISOString() });
291
+ return this.setExpiry({ _id: id, at: at.toISOString() });
287
292
  }
288
293
  // Removes the expiry of a document (PERSIST in Redis).
289
294
  async persist(id) {
290
295
  await this.ready();
291
- return this.db.transport.request("POST", this.path("persist"), { id });
296
+ return this.db.transport.request("POST", this.path("persist"), { _id: id });
292
297
  }
293
298
  // The seconds left (rounded up), -1 when the document never expires, null
294
299
  // when it is missing or expired.
295
300
  async ttl(id) {
296
301
  await this.ready();
297
302
  try {
298
- const out = await this.db.transport.request("GET", `${this.path("ttl")}/${encodeURIComponent(id)}`);
303
+ const out = await this.db.transport.request("GET", this.path(`${encodeURIComponent(id)}/ttl`));
299
304
  return out.ttl;
300
305
  } catch (err) {
301
306
  if (err instanceof NotFoundError) {
@@ -307,10 +312,20 @@ var Model = class {
307
312
  async setExpiry(body) {
308
313
  await this.ready();
309
314
  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) };
315
+ return { _id: out._id, v: out.v, expiresAt: new Date(out.expires_at) };
311
316
  }
312
- find(id) {
313
- return this.query().where({ [this.idField]: id }).first();
317
+ // The document with this _id, or null.
318
+ async get(id) {
319
+ await this.ready();
320
+ try {
321
+ const doc = await this.db.transport.request("GET", this.path(encodeURIComponent(id)));
322
+ return this.decode(doc);
323
+ } catch (err) {
324
+ if (err instanceof NotFoundError) {
325
+ return null;
326
+ }
327
+ throw err;
328
+ }
314
329
  }
315
330
  query() {
316
331
  return new Query(this);
@@ -387,7 +402,6 @@ var Model = class {
387
402
  catalog() {
388
403
  const entries = Object.entries(this.schema.fields);
389
404
  return {
390
- id: this.schema.id,
391
405
  time: this.schema.time,
392
406
  expires_field: this.schema.expires,
393
407
  indexes: entries.filter(([, d]) => d.index).map(([n]) => n),
@@ -612,6 +626,38 @@ var Ndb = class {
612
626
  function connect(addr, opts) {
613
627
  return new Ndb(addr, opts);
614
628
  }
629
+
630
+ // src/id.ts
631
+ var lastMs = 0;
632
+ var counter = 0;
633
+ function newId() {
634
+ const bytes = new Uint8Array(16);
635
+ crypto.getRandomValues(bytes);
636
+ const now = Date.now();
637
+ if (now > lastMs) {
638
+ lastMs = now;
639
+ counter = seedCounter();
640
+ } else if (counter === 4095) {
641
+ lastMs += 1;
642
+ counter = seedCounter();
643
+ } else {
644
+ counter += 1;
645
+ }
646
+ const ms = BigInt(lastMs);
647
+ for (let i = 0; i < 6; i++) {
648
+ bytes[i] = Number(ms >> BigInt(8 * (5 - i)) & 0xffn);
649
+ }
650
+ bytes[6] = 112 | counter >> 8;
651
+ bytes[7] = counter & 255;
652
+ bytes[8] = 128 | bytes[8] & 63;
653
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
654
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
655
+ }
656
+ function seedCounter() {
657
+ const r = new Uint16Array(1);
658
+ crypto.getRandomValues(r);
659
+ return r[0] & 2047;
660
+ }
615
661
  export {
616
662
  CursorError,
617
663
  DuplicateIdError,
@@ -628,5 +674,6 @@ export {
628
674
  UniqueViolationError,
629
675
  WatchLaggedError,
630
676
  connect,
631
- makeError
677
+ makeError,
678
+ newId
632
679
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thadeu/ndb",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "description": "TypeScript client for the ndb daemon",
5
5
  "keywords": [
6
6
  "ndb",