@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/dist/index.js CHANGED
@@ -108,16 +108,23 @@ function toWhere(v) {
108
108
 
109
109
  // src/query.ts
110
110
  var Query = class _Query {
111
- constructor(model, state = { clauses: [] }) {
112
- this.model = model;
111
+ constructor(coll, state = { clauses: [] }) {
112
+ this.coll = coll;
113
113
  this.state = state;
114
114
  }
115
- model;
115
+ coll;
116
116
  state;
117
117
  nextCursor = "";
118
- where(w) {
118
+ // Adds conditions, ANDed with the ones already there.
119
+ query(w) {
119
120
  return this.with({ clauses: [...this.state.clauses, toWhere(w)] });
120
121
  }
122
+ // A query built elsewhere, such as from a search form. It is not
123
+ // type-checked and is sent as is; the daemon validates it. It is ANDed
124
+ // with the other clauses.
125
+ rawQuery(w) {
126
+ return this.with({ clauses: [...this.state.clauses, w] });
127
+ }
121
128
  not(w) {
122
129
  return this.with({ clauses: [...this.state.clauses, { $not: toWhere(w) }] });
123
130
  }
@@ -158,14 +165,15 @@ var Query = class _Query {
158
165
  }
159
166
  return null;
160
167
  }
168
+ // How many documents match (POST /{coll}/_count). limit, skip and sort do
169
+ // not change it.
161
170
  async count() {
162
- await this.model.ready();
163
- const body = { where: this.whereDoc(), count: true, limit: 1 };
164
- const res = await this.model.db.transport.stream("POST", this.model.path("find"), body);
165
- await res.body.dump();
166
- return Number(res.header("x-ndb-total") ?? 0);
171
+ await this.coll.ready();
172
+ const out = await this.coll.db.transport.request("POST", this.coll.path("_count"), { query: this.whereDoc() });
173
+ return out.count;
167
174
  }
168
- async *iterate() {
175
+ // Every row, following the cursor page by page (limit is the page size).
176
+ async *each() {
169
177
  let body = this.body();
170
178
  for (; ; ) {
171
179
  yield* this.rows(body);
@@ -176,16 +184,16 @@ var Query = class _Query {
176
184
  }
177
185
  }
178
186
  async *rows(body) {
179
- await this.model.ready();
180
- const res = await this.model.db.transport.stream("POST", this.model.path("find"), body);
187
+ await this.coll.ready();
188
+ const res = await this.coll.db.transport.stream("POST", this.coll.path("_search"), body);
181
189
  this.nextCursor = res.header("x-ndb-next") ?? "";
182
190
  for await (const raw of res.rows()) {
183
- yield this.model.decode(raw);
191
+ yield this.coll.decode(raw);
184
192
  }
185
193
  }
186
194
  body() {
187
195
  const { fields, sort, limit, skip, after } = this.state;
188
- return { where: this.whereDoc(), fields, sort, limit, skip, after, versions: true };
196
+ return { query: this.whereDoc(), fields, sort, limit, skip, after, versions: true };
189
197
  }
190
198
  whereDoc() {
191
199
  const merged = {};
@@ -200,7 +208,7 @@ var Query = class _Query {
200
208
  return merged;
201
209
  }
202
210
  with(patch) {
203
- return new _Query(this.model, { ...this.state, ...patch });
211
+ return new _Query(this.coll, { ...this.state, ...patch });
204
212
  }
205
213
  };
206
214
 
@@ -214,12 +222,12 @@ async function* watchEvents(lines2, consumer) {
214
222
  if (line.error) {
215
223
  throw makeError(line.error.code, line.error.message);
216
224
  }
217
- yield { consumer, pos: line.pos, op: line.op, _id: line._id, v: line.v, doc: line.doc };
225
+ yield { consumer, pos: line.pos, op: line.op, _id: line._id ?? "", _v: line._v, doc: line.doc };
218
226
  }
219
227
  }
220
228
 
221
- // src/model.ts
222
- var Model = class {
229
+ // src/collection.ts
230
+ var Collection = class {
223
231
  constructor(db, name, schema) {
224
232
  this.db = db;
225
233
  this.name = name;
@@ -229,11 +237,16 @@ var Model = class {
229
237
  name;
230
238
  schema;
231
239
  ensured;
232
- path(op) {
233
- const base = `/v1/c/${encodeURIComponent(this.name)}`;
234
- return op ? `${base}/${op}` : base;
240
+ // The path of the collection, or of one of its actions ("_search").
241
+ path(action) {
242
+ const base = `/${encodeURIComponent(this.name)}`;
243
+ return action ? `${base}/${action}` : base;
244
+ }
245
+ get isPattern() {
246
+ return this.name.includes("*");
235
247
  }
236
248
  ready() {
249
+ if (this.isPattern) return Promise.resolve();
237
250
  this.ensured ??= this.db.transport.request("PUT", this.path(), this.catalog()).catch((err) => {
238
251
  this.ensured = void 0;
239
252
  throw err;
@@ -241,137 +254,167 @@ var Model = class {
241
254
  return this.ensured;
242
255
  }
243
256
  async insert(docs, opts = {}) {
244
- await this.ready();
257
+ if (!Array.isArray(docs) && opts.expireIn === void 0) {
258
+ return this.send("POST", "_doc", docs);
259
+ }
245
260
  const list = Array.isArray(docs) ? docs : [docs];
246
- const out = await this.db.transport.request("POST", this.path("insert"), {
247
- insert: list,
261
+ const out = await this.send("POST", "_bulk", { docs: list, expire_in: opts.expireIn });
262
+ return Array.isArray(docs) ? out.results : out.results[0];
263
+ }
264
+ // Writes a document by its _id: creates it when the _id is free, and
265
+ // merges its fields into the stored document when it exists (the other
266
+ // fields stay), like doc_as_upsert in Elasticsearch. No _id is an insert.
267
+ async upsert(doc, opts = {}) {
268
+ return (await this.upsertAll([doc], opts))[0];
269
+ }
270
+ // upsert for many documents in one transaction, all or nothing
271
+ // (POST /{coll}/_bulk with upsert).
272
+ async upsertAll(docs, opts = {}) {
273
+ const out = await this.send("POST", "_bulk", { docs, expire_in: opts.expireIn, upsert: true });
274
+ return out.results;
275
+ }
276
+ // Applies ops to the document with this _id, in one atomic step
277
+ // (POST /{coll}/_update/{id}). guard adds conditions on the same document,
278
+ // such as { _v: 7 }: matched is 0 when it fails. Matching nothing is not an
279
+ // error.
280
+ async update(id, ops, opts = {}) {
281
+ const out = await this.send("POST", docPath("_update", id), {
282
+ query: opts.guard === void 0 ? void 0 : toWhere(opts.guard),
283
+ update: checkOps(ops),
284
+ upsert: opts.upsert,
248
285
  expire_in: opts.expireIn
249
286
  });
250
- return out.results;
287
+ return this.updated(out);
288
+ }
289
+ // The atomic update by query (POST /{coll}/_update_by_query): the daemon
290
+ // matches and writes in one step on the newest state, so concurrent updates
291
+ // never interleave. Without many it updates the first match; many updates
292
+ // every match and needs _id or an indexed field in the query.
293
+ async updateBy(query, ops, opts = {}) {
294
+ const out = await this.send("POST", "_update_by_query", {
295
+ query: toWhere(query),
296
+ update: checkOps(ops),
297
+ many: opts.many,
298
+ upsert: opts.upsert,
299
+ expire_in: opts.expireIn
300
+ });
301
+ return this.updated(out);
251
302
  }
252
- create(docs, opts = {}) {
253
- return this.insert(docs, opts);
303
+ // Deletes the document with this _id (DELETE /{coll}/_doc/{id}).
304
+ delete(id) {
305
+ return this.send("DELETE", docPath("_doc", id));
254
306
  }
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
258
- // concurrent updates never interleave. A version guard is a filter:
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 = {}) {
262
- await this.ready();
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";
267
- const out = await this.db.transport.request(
268
- "POST",
269
- this.path(op) + query,
270
- body
271
- );
272
- return { matched: out.matched, modified: out.modified, doc: out.doc && this.decode(out.doc) };
307
+ // Deletes the first document the query matches, or every match with many
308
+ // (POST /{coll}/_delete_by_query, same rule as updateBy).
309
+ deleteBy(query, opts = {}) {
310
+ return this.send("POST", "_delete_by_query", { query: toWhere(query), many: opts.many });
273
311
  }
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 = {}) {
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;
282
- return this.db.transport.request("POST", this.path("delete") + writeQuery({ many: opts.many }), toWhere(filter));
312
+ // Removes every document and keeps the definition and indexes. The cost
313
+ // does not depend on the number of documents. Watch consumers get one event
314
+ // with op "truncate".
315
+ async truncate() {
316
+ await this.db.transport.request("POST", this.path("_truncate") + this.confirm());
317
+ }
318
+ // Removes the collection: its documents, its definition and the positions
319
+ // of its watch consumers. The next call declares it again.
320
+ async drop() {
321
+ this.ensured = void 0;
322
+ await this.db.transport.request("DELETE", this.path() + this.confirm());
323
+ }
324
+ confirm() {
325
+ return `?confirm=${encodeURIComponent(this.name)}`;
283
326
  }
284
327
  // Makes a document expire, like EXPIRE in Redis: seconds or "30m". It throws
285
328
  // NotFoundError for a missing or expired document, and TtlError when the
286
329
  // schema has no expires field.
287
330
  expire(id, duration) {
288
- return this.setExpiry({ _id: id, in: duration });
331
+ return this.setExpiry(id, { in: duration });
289
332
  }
290
333
  expireAt(id, at) {
291
- return this.setExpiry({ _id: id, at: at.toISOString() });
334
+ return this.setExpiry(id, { at: at.toISOString() });
292
335
  }
293
336
  // Removes the expiry of a document (PERSIST in Redis).
294
- async persist(id) {
295
- await this.ready();
296
- return this.db.transport.request("POST", this.path("persist"), { _id: id });
337
+ persist(id) {
338
+ return this.send("POST", docPath("_persist", id));
297
339
  }
298
340
  // The seconds left (rounded up), -1 when the document never expires, null
299
341
  // when it is missing or expired.
300
342
  async ttl(id) {
301
- await this.ready();
302
- try {
303
- const out = await this.db.transport.request("GET", this.path(`${encodeURIComponent(id)}/ttl`));
304
- return out.ttl;
305
- } catch (err) {
306
- if (err instanceof NotFoundError) {
307
- return null;
308
- }
309
- throw err;
310
- }
343
+ const out = await orNull(this.send("GET", docPath("_ttl", id)));
344
+ return out === null ? null : out.ttl;
311
345
  }
312
- async setExpiry(body) {
313
- await this.ready();
314
- const out = await this.db.transport.request("POST", this.path("expire"), body);
315
- return { _id: out._id, v: out.v, expiresAt: new Date(out.expires_at) };
346
+ async setExpiry(id, body) {
347
+ const out = await this.send("POST", docPath("_expire", id), body);
348
+ return { _id: out._id, _v: out._v, expiresAt: new Date(out.expires_at) };
316
349
  }
317
350
  // The document with this _id, or null.
318
351
  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
- }
352
+ const doc = await orNull(this.send("GET", docPath("_doc", id)));
353
+ return doc === null ? null : this.decode(doc);
329
354
  }
330
- query() {
331
- return new Query(this);
355
+ // A search (POST /{coll}/_search). No query matches every document.
356
+ query(w) {
357
+ const q = new Query(this);
358
+ return w === void 0 ? q : q.query(w);
332
359
  }
333
- where(w) {
334
- return this.query().where(w);
360
+ // A query built elsewhere, such as from a search form: it is not
361
+ // type-checked and is sent as is; the daemon validates it.
362
+ rawQuery(w) {
363
+ return new Query(this).rawQuery(w);
335
364
  }
336
365
  not(w) {
337
- return this.query().not(w);
366
+ return new Query(this).not(w);
338
367
  }
339
368
  or(...ws) {
340
- return this.query().or(...ws);
369
+ return new Query(this).or(...ws);
341
370
  }
342
371
  sort(spec) {
343
- return this.query().sort(spec);
372
+ return new Query(this).sort(spec);
344
373
  }
345
374
  limit(n) {
346
- return this.query().limit(n);
375
+ return new Query(this).limit(n);
347
376
  }
348
377
  skip(n) {
349
- return this.query().skip(n);
378
+ return new Query(this).skip(n);
350
379
  }
351
380
  page(n, size) {
352
- return this.query().page(n, size);
381
+ return new Query(this).page(n, size);
353
382
  }
354
383
  after(token) {
355
- return this.query().after(token);
384
+ return new Query(this).after(token);
356
385
  }
357
386
  fields(...names) {
358
- return this.query().fields(...names);
387
+ return new Query(this).fields(...names);
359
388
  }
360
389
  all() {
361
- return this.query().all();
390
+ return new Query(this).all();
362
391
  }
363
- count() {
364
- return this.query().count();
392
+ // How many documents match (POST /{coll}/_count).
393
+ count(w) {
394
+ return this.query(w).count();
365
395
  }
366
396
  first() {
367
- return this.query().first();
397
+ return new Query(this).first();
368
398
  }
369
- iterate() {
370
- return this.query().iterate();
399
+ // Every row, page by page.
400
+ each() {
401
+ return new Query(this).each();
402
+ }
403
+ // Runs a whole search body built elsewhere, such as from a search form:
404
+ // { query, sort, limit, after, count }. The body is sent as is (the daemon
405
+ // validates it); rows are decoded like the rows of query().
406
+ async search(body) {
407
+ await this.ready();
408
+ const res = await this.db.transport.stream("POST", this.path("_search"), { ...body, versions: true });
409
+ const rows = [];
410
+ for await (const raw of res.rows()) {
411
+ rows.push(this.decode(raw));
412
+ }
413
+ return { rows, next: res.header("x-ndb-next") ?? "", total: Number(res.header("x-ndb-total") ?? 0) };
371
414
  }
372
415
  async aggregate(pipeline, opts = {}) {
373
416
  await this.ready();
374
- const res = await this.db.transport.stream("POST", this.path("aggregate"), { pipeline, ...opts });
417
+ const res = await this.db.transport.stream("POST", this.path("_aggregate"), { pipeline, ...opts });
375
418
  const rows = [];
376
419
  for await (const row of res.rows()) {
377
420
  rows.push(row);
@@ -380,14 +423,30 @@ var Model = class {
380
423
  }
381
424
  async *watch(consumer, opts = {}) {
382
425
  await this.ready();
383
- const body = { consumer, from: opts.from, limit: opts.limit, where: opts.where === void 0 ? void 0 : toWhere(opts.where) };
384
- const res = await this.db.transport.stream("POST", this.path("watch"), body, opts.signal);
426
+ const body = { consumer, from: opts.from, limit: opts.limit, query: opts.query === void 0 ? void 0 : toWhere(opts.query) };
427
+ const res = await this.db.transport.stream("POST", this.path("_watch"), body, opts.signal);
385
428
  for await (const ev of watchEvents(res.rows(), consumer)) {
386
429
  yield { ...ev, doc: ev.doc && this.decode(ev.doc) };
387
430
  }
388
431
  }
389
432
  async ack(event) {
390
- await this.db.transport.request("POST", this.path("ack"), { consumer: event.consumer, pos: event.pos });
433
+ await this.send("POST", "_ack", { consumer: event.consumer, pos: event.pos });
434
+ }
435
+ // Rows, indexes, files and bytes (GET /{coll}/_stats).
436
+ stats() {
437
+ return this.send("GET", "_stats");
438
+ }
439
+ // Seals the memtable and merges every segment now (POST /{coll}/_compact).
440
+ compact() {
441
+ return this.send("POST", "_compact");
442
+ }
443
+ // Adds a secondary index and builds it in the background. Returns the
444
+ // indexed fields.
445
+ async ensureIndex(field) {
446
+ return (await this.send("PUT", docPath("_indexes", field))).indexes;
447
+ }
448
+ async dropIndex(field) {
449
+ return (await this.send("DELETE", docPath("_indexes", field))).indexes;
391
450
  }
392
451
  decode(raw) {
393
452
  const out = { ...raw };
@@ -399,6 +458,15 @@ var Model = class {
399
458
  }
400
459
  return out;
401
460
  }
461
+ // Declares the collection on first use, then sends one request to
462
+ // /{coll}/{action}.
463
+ async send(method, action, body) {
464
+ await this.ready();
465
+ return this.db.transport.request(method, this.path(action), body);
466
+ }
467
+ updated(out) {
468
+ return { matched: out.matched, modified: out.modified, doc: out.doc && this.decode(out.doc) };
469
+ }
402
470
  catalog() {
403
471
  const entries = Object.entries(this.schema.fields);
404
472
  return {
@@ -409,23 +477,26 @@ var Model = class {
409
477
  };
410
478
  }
411
479
  };
412
- function writeQuery(params) {
413
- const q = new URLSearchParams();
414
- for (const [name, value] of Object.entries(params)) {
415
- if (value !== void 0 && value !== false) {
416
- q.set(name, String(value));
417
- }
418
- }
419
- const text = q.toString();
420
- return text === "" ? "" : `?${text}`;
480
+ function docPath(action, id) {
481
+ return `${action}/${encodeURIComponent(id)}`;
421
482
  }
422
- function mergeUpdate(filter, ops) {
483
+ function checkOps(ops) {
423
484
  for (const key of Object.keys(ops)) {
424
485
  if (!key.startsWith("$")) {
425
486
  throw new InvalidUpdateError("invalid_update", `update operator "${key}" must start with $ (for example "$set")`);
426
487
  }
427
488
  }
428
- return { ...toWhere(filter), ...ops };
489
+ return ops;
490
+ }
491
+ async function orNull(p) {
492
+ try {
493
+ return await p;
494
+ } catch (err) {
495
+ if (err instanceof NotFoundError) {
496
+ return null;
497
+ }
498
+ throw err;
499
+ }
429
500
  }
430
501
 
431
502
  // src/transport.ts
@@ -463,7 +534,7 @@ var notConnected = /* @__PURE__ */ new Set(["ECONNREFUSED", "ENOENT"]);
463
534
  var retryBaseMs = 50;
464
535
  var retryCapMs = 1e3;
465
536
  var defaultReadyTimeoutMs = 1e4;
466
- var readOps = /\/(find|count|aggregate|get|stats)$/;
537
+ var readOps = /\/(_search|_count|_aggregate)$/;
467
538
  var Transport = class {
468
539
  client;
469
540
  token;
@@ -482,9 +553,9 @@ var Transport = class {
482
553
  throw new TypeError(`ndb: address "${target}" must be unix:///path or tcp://host:port`);
483
554
  }
484
555
  }
485
- async stream(method, path, body, signal) {
556
+ async stream(method, path, body, signal, contentType) {
486
557
  const res = await this.withReadyRetry(method, path, signal, async () => {
487
- const r = await this.send(method, path, body, signal);
558
+ const r = await this.send(method, path, body, signal, contentType);
488
559
  if (r.statusCode >= 300) {
489
560
  throw await apiError(r);
490
561
  }
@@ -498,8 +569,9 @@ var Transport = class {
498
569
  header: (name) => headerOf(res.headers, name)
499
570
  };
500
571
  }
501
- async request(method, path, body) {
502
- const res = await this.stream(method, path, body);
572
+ // contentType sends body as raw bytes (blobs) instead of JSON.
573
+ async request(method, path, body, contentType) {
574
+ const res = await this.stream(method, path, body, void 0, contentType);
503
575
  const text = await res.body.setEncoding("utf8").toArray();
504
576
  return text.length > 0 ? JSON.parse(text.join("")) : void 0;
505
577
  }
@@ -527,20 +599,22 @@ var Transport = class {
527
599
  }
528
600
  }
529
601
  }
530
- headers() {
531
- const headers = { "content-type": "application/json" };
602
+ // Rows come as NDJSON: the SDK reads them one by one (jsonRows).
603
+ headers(contentType = "application/json") {
604
+ const headers = { "content-type": contentType, accept: "application/x-ndjson" };
532
605
  if (this.token) {
533
606
  headers.authorization = `Bearer ${this.token}`;
534
607
  }
535
608
  return headers;
536
609
  }
537
- async send(method, path, body, signal) {
610
+ async send(method, path, body, signal, contentType) {
611
+ const raw = contentType !== void 0;
538
612
  const opts = {
539
613
  method,
540
614
  path,
541
615
  signal,
542
- headers: this.headers(),
543
- body: body === void 0 ? void 0 : JSON.stringify(body)
616
+ headers: this.headers(contentType),
617
+ body: body === void 0 ? void 0 : raw ? body : JSON.stringify(body)
544
618
  };
545
619
  try {
546
620
  return await this.client.request(opts);
@@ -607,11 +681,37 @@ var Ndb = class {
607
681
  constructor(addr, opts) {
608
682
  this.transport = new Transport(addr, opts);
609
683
  }
610
- model(name, schema) {
611
- return new Model(this, name, schema);
684
+ // A collection of the daemon; see Collection. Nothing is sent until its
685
+ // first call.
686
+ collection(name, schema) {
687
+ return new Collection(this, name, schema);
612
688
  }
689
+ // Resolves when the daemon is ready (GET /_health).
613
690
  async health() {
614
- await this.transport.request("GET", "/health");
691
+ await this.transport.request("GET", "/_health");
692
+ }
693
+ // The catalog: one definition per collection (GET /_collections).
694
+ async collections() {
695
+ return (await this.transport.request("GET", "/_collections")).collections;
696
+ }
697
+ // Every collection, totals, version and uptime (GET /_stats).
698
+ stats() {
699
+ return this.transport.request("GET", "/_stats");
700
+ }
701
+ // Disk use per collection and per part (GET /_size).
702
+ size() {
703
+ return this.transport.request("GET", "/_size");
704
+ }
705
+ // Stores bytes in the blob store (POST /_blobs) and returns the reference
706
+ // to put in a document field.
707
+ putBlob(data, contentType = "application/octet-stream") {
708
+ return this.transport.request("POST", "/_blobs", data, contentType);
709
+ }
710
+ // The bytes of a blob (GET /_blobs/{ref}).
711
+ async getBlob(blob) {
712
+ const ref = typeof blob === "string" ? blob : blob.$blob;
713
+ const res = await this.transport.stream("GET", `/_blobs/${encodeURIComponent(ref)}`);
714
+ return new Uint8Array(await res.body.arrayBuffer());
615
715
  }
616
716
  request(method, path, body) {
617
717
  return this.transport.request(method, path, body);
@@ -659,12 +759,12 @@ function seedCounter() {
659
759
  return r[0] & 2047;
660
760
  }
661
761
  export {
762
+ Collection,
662
763
  CursorError,
663
764
  DuplicateIdError,
664
765
  HistoryTruncatedError,
665
766
  InvalidQueryError,
666
767
  InvalidUpdateError,
667
- Model,
668
768
  Ndb,
669
769
  NdbError,
670
770
  NotFoundError,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thadeu/ndb",
3
- "version": "0.3.2",
3
+ "version": "1.0.0",
4
4
  "description": "TypeScript client for the ndb daemon",
5
5
  "keywords": [
6
6
  "ndb",