@dokebi/search 0.1.0 → 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/AI.md CHANGED
@@ -70,12 +70,12 @@ try {
70
70
  }
71
71
  ```
72
72
 
73
- - **`index(tx, index, item)`** writes in the transaction of `tx` (the pg client of `BEGIN`…`COMMIT`, or the mysql2/mariadb connection of the transaction). `item` is the item's **whole** searchable text: `id` (1–128), `sortKey` (a safe integer: the display order, newest first — usually the creation time in ms), `scopes` (the filters a search is combined with, at most 32 of at most 64 characters; default `['']`, everything), `fields` (a text per declared field; absent or null has no terms; at most 1,000,000 characters). It reads the item's terms by the item index and writes only the difference; a new `sortKey` rewrites them. Call it in the transaction that changes the item's row, so that row's lock orders two writers of the same item.
73
+ - **`index(tx, index, item)`** writes in the transaction of `tx` (the pg client of `BEGIN`…`COMMIT`, or the mysql2/mariadb connection of the transaction). `item` is the item's **whole** searchable text: `id` (1–128), `sortKey` (a safe integer: the display order, newest first — usually the creation time in ms), `scopes` (the filters a search is combined with, at most 32 of at most 64 characters; default `['']`, everything), `fields` (a text per declared field; absent or null has no terms; at most 1,000,000 characters). It reads the item's terms by the item index and writes only the difference; a new `sortKey` rewrites them. Call it in the transaction that changes the item's row, so that row's lock orders two writers of the same item. On MySQL and MariaDB a transaction reads the snapshot of its first plain read, which may predate another writer's commit of the item: after its own inserts the write counts the item's rows as committed now (a locking read) and, when there are more than its new text, removes the others (measured on a 1,000-character edit: p50 7.4 → 9.0 ms on MySQL 8, 6.2 → 7.0 ms on MariaDB; PostgreSQL's READ COMMITTED statements read the committed rows and skip it). A deadlock, lock wait timeout or changed snapshot (MariaDB errno 1020) of your transaction comes back as the driver's own error, not `STORE_FAILED`: run the whole transaction again (@dokebi/db AI.md 6.4); a route that does not answers 503 `DB_BUSY`.
74
74
  - **Scopes** are how a search is filtered without reading other rows: an item is indexed under each, so a board and its ancestor categories are `['', 'board:12', 'cat:3', 'cat:3/7']` and a search names one. Each scope adds the item's terms again: keep them few.
75
- - **`remove(tx, index, id)`** when the item is hidden or deleted → `{ removed }`. Items a viewer may not see must not be in the index (or in a scope that viewer searches): `find` answers ids, it does not check rights.
75
+ - **`remove(tx, index, id)`** when the item is hidden or deleted → `{ removed }` (on MySQL and MariaDB it reads the item's rows as committed now, as above). Items a viewer may not see must not be in the index (or in a scope that viewer searches): `find` answers ids, it does not check rights.
76
76
  - **`indexMany(tx, index, items)`**: a first fill or a re-index, in batches of a few hundred per transaction from a job; then `flush()` and `markCurrent(index)`.
77
77
  - **A display order that changes all the time** (views, likes) is not a `sortKey`: every change would rewrite every term row. Search in the index's order and rank a capped set of results yourself, saying so.
78
- - **Word counts** (which word is rarest) are a hint the plugin adds after the write, outside your transaction: each worker buffers them and writes them every `counts.flushMs`, at drain and on `flush()`. A common word's count is never a row every writer waits on, and a rolled-back write leaves its count changed: the counts may drift, the results never do.
78
+ - **Word counts** (which word is rarest) are a hint the plugin adds after the write, outside your transaction: each worker buffers them and writes them every `counts.flushMs`, at drain and on `flush()` (which also writes what was buffered while an earlier flush was running). A common word's count is never a row every writer waits on, and a rolled-back write leaves its count changed: the counts may drift, the results never do.
79
79
 
80
80
  ## 4. Search
81
81
 
@@ -121,7 +121,7 @@ debug ([D-0047](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/decis
121
121
  | `UNSUPPORTED_RESOURCE` | the connection is not a pg, mysql2/promise or mariadb pool (MongoDB) |
122
122
  | `TABLE_MISSING` | the tables or their columns are missing at start (run the migration) |
123
123
  | `NOT_READY` | the database of an optional connection is down (503) |
124
- | `STORE_FAILED` | a statement failed (the driver error is the cause) |
124
+ | `STORE_FAILED` | a statement failed (the driver error is the cause); deadlocks, lock waits and changed snapshots in your transaction come out as the driver's own error instead |
125
125
  | `DESCRIBING` | a call during `dokebi describe` |
126
126
 
127
127
  ## 7. Cautions
package/dist/plugin.js CHANGED
@@ -4,7 +4,7 @@ import { Cursors, queryFingerprint } from './cursor.js';
4
4
  import { SearchError } from './errors.js';
5
5
  import { indexesOf } from './indexes.js';
6
6
  import { DEFAULT_SECRETS_ENV, MIN_SECRET_LENGTH, options } from './options.js';
7
- import { detectDriver, isMissingTable, isQueryable, serverDialect, session, } from './sql.js';
7
+ import { detectDriver, isMissingTable, isQueryable, isRetryable, serverDialect, session, } from './sql.js';
8
8
  import { COUNTS_TABLE, INDEXES_TABLE, Store, TERMS_TABLE, } from './store.js';
9
9
  import { MAX_QUERY_WORDS, queryTerms, terms } from './terms.js';
10
10
  const db = providerDependency({
@@ -130,7 +130,7 @@ class SearchRuntime {
130
130
  }
131
131
  this.#repeats = new diagnostics.Repeats({ logger: context.logger, intervalMs: REPEAT_MS });
132
132
  const timer = setInterval(() => {
133
- void this.#flush();
133
+ this.#flushSoon();
134
134
  }, settings.counts.flushMs);
135
135
  timer.unref();
136
136
  context.defer(async () => {
@@ -241,8 +241,11 @@ class SearchRuntime {
241
241
  data: { connection: this.#options.connection, retryAfterMs: RETRY_MS },
242
242
  });
243
243
  }
244
- /** A statement failure as a coded error that keeps the driver's cause. */
245
- async #run(operation, work) {
244
+ /**
245
+ * A statement failure as a coded error that keeps the driver's cause; `inCaller`: the work ran
246
+ * in the caller's transaction.
247
+ */
248
+ async #run(operation, work, inCaller = false) {
246
249
  const live = await this.#ready();
247
250
  try {
248
251
  return await work(live);
@@ -256,6 +259,10 @@ class SearchRuntime {
256
259
  operation,
257
260
  error: diagnostics.describeError(error),
258
261
  });
262
+ // The caller's transaction ended by a deadlock, a lock wait or a changed snapshot: its own
263
+ // error, so the caller's retry sees it and @dokebi/db answers 503 DB_BUSY / DB_TIMEOUT.
264
+ if (inCaller && isRetryable(error))
265
+ throw error;
259
266
  throw new SearchError('STORE_FAILED', `The search could not ${operation}`, {
260
267
  cause: error,
261
268
  data: { operation },
@@ -351,6 +358,23 @@ class SearchRuntime {
351
358
  await live.store.deleteRows(tx, index, item.id, gone);
352
359
  if (added.length)
353
360
  await live.store.insertRows(tx, index, item.id, item.sortKey, added);
361
+ // After the inserts the item holds at least its new rows, once each: only more means rows
362
+ // this transaction's snapshot did not show (one small locking read, the rows only then).
363
+ if (live.store.snapshots &&
364
+ (await live.store.lockedItemCount(tx, index, item.id)) !== rows.length) {
365
+ // MySQL and MariaDB: `old` came from the transaction's snapshot, which may predate another
366
+ // writer's commit of this item (its first plain read fixed it). Rows that writer added are
367
+ // read as committed now and removed (R8-g2-2); their words count as the item's before.
368
+ const extra = (await live.store.lockedItemRows(tx, index, item.id)).filter((row) => row.sortKey !== item.sortKey || !now.has(key(row)));
369
+ for (const row of extra)
370
+ oldPairs.add(`${row.field}${SEP}${row.term}`);
371
+ if (extra.some((row) => row.sortKey !== item.sortKey)) {
372
+ await live.store.deleteItem(tx, index, item.id);
373
+ await live.store.insertRows(tx, index, item.id, item.sortKey, rows);
374
+ }
375
+ else if (extra.length)
376
+ await live.store.deleteRows(tx, index, item.id, extra);
377
+ }
354
378
  }
355
379
  this.#count(index, oldPairs, pairs);
356
380
  }
@@ -371,7 +395,7 @@ class SearchRuntime {
371
395
  if (!after.has(pair))
372
396
  add(pair, -1);
373
397
  if (this.#pending.size >= FLUSH_WORDS)
374
- void this.#flush();
398
+ this.#flushSoon();
375
399
  }
376
400
  /**
377
401
  * Adds the buffered count changes to the table, one autocommit statement per index on the
@@ -379,8 +403,18 @@ class SearchRuntime {
379
403
  * next flush. Never rejects.
380
404
  */
381
405
  #flush() {
406
+ // A flush in flight took the buffer before the newest changes: an explicit flush (flush(),
407
+ // the drain, the stop) waits for it, then writes what was buffered since.
382
408
  if (this.#flushing)
383
- return this.#flushing;
409
+ return this.#flushing.then(() => this.#flush());
410
+ return this.#flushNow();
411
+ }
412
+ /** The timer's and the buffer-size flush: none while one is in flight (it runs again soon). */
413
+ #flushSoon() {
414
+ if (!this.#flushing)
415
+ void this.#flushNow();
416
+ }
417
+ #flushNow() {
384
418
  if (!this.#pending.size || !this.#live)
385
419
  return Promise.resolve();
386
420
  const live = this.#live;
@@ -582,7 +616,7 @@ class SearchRuntime {
582
616
  },
583
617
  // The item is checked (and its terms made) once, inside #write: a SearchError passes #run.
584
618
  index: async (tx, index, item) => {
585
- await this.#run('index an item', async (live) => this.#write(live, session(live.driver, tx), index, item));
619
+ await this.#run('index an item', async (live) => this.#write(live, session(live.driver, tx), index, item), true);
586
620
  },
587
621
  indexMany: async (tx, index, items) => {
588
622
  if (!Array.isArray(items))
@@ -591,20 +625,23 @@ class SearchRuntime {
591
625
  const client = session(live.driver, tx);
592
626
  for (const item of items)
593
627
  await this.#write(live, client, index, item);
594
- });
628
+ }, true);
595
629
  },
596
630
  remove: async (tx, index, id) => {
597
631
  this.#fields(index);
598
632
  const item = this.#id(id);
599
633
  return this.#run('remove an item', async (live) => {
600
634
  const client = session(live.driver, tx);
601
- const old = await live.store.itemRows(client, index, item);
635
+ // MySQL and MariaDB: as committed now, not as the transaction's snapshot (R8-g2-2).
636
+ const old = live.store.snapshots
637
+ ? await live.store.lockedItemRows(client, index, item)
638
+ : await live.store.itemRows(client, index, item);
602
639
  if (!old.length)
603
640
  return Object.freeze({ removed: false });
604
641
  await live.store.deleteItem(client, index, item);
605
642
  this.#count(index, new Set(old.map((row) => `${row.field}${SEP}${row.term}`)), new Set());
606
643
  return Object.freeze({ removed: true });
607
- });
644
+ }, true);
608
645
  },
609
646
  find: async (index, query) => {
610
647
  return this.#run('search', (live) => this.#find(live, index, query));
@@ -660,7 +697,7 @@ class SearchRuntime {
660
697
  /** Factory input: `{ app, options?, entry }`: `entry` is the module of src/infra/search/indexes.ts. */
661
698
  export const createPlugin = definePlugin({
662
699
  name: 'dokebi-search',
663
- version: '0.1.0',
700
+ version: '0.1.1',
664
701
  core: '^0.4.2',
665
702
  options,
666
703
  dependencies: () => [db],
package/dist/sql.d.ts CHANGED
@@ -26,6 +26,11 @@ export declare function serverDialect(driver: Driver, pool: Session): Promise<{
26
26
  }>;
27
27
  /** A missing table or column (PostgreSQL 42P01, 42703; MySQL and MariaDB 1146, 1054). */
28
28
  export declare function isMissingTable(error: unknown): boolean;
29
+ /**
30
+ * A deadlock, serialization failure or lock wait timeout (PostgreSQL 40001, 40P01, 55P03; MySQL
31
+ * and MariaDB 1213, 1020, 1205): the caller's transaction is over and is run again whole.
32
+ */
33
+ export declare function isRetryable(error: unknown): boolean;
29
34
  /** A number from a driver value: pg bigint strings, mariadb BigInt, mysql2 numbers. */
30
35
  export declare function num(value: unknown): number;
31
36
  export declare function text(value: unknown): string | undefined;
package/dist/sql.js CHANGED
@@ -95,6 +95,16 @@ export function isMissingTable(error) {
95
95
  const value = error;
96
96
  return MISSING_TABLE.has(value?.code) || MISSING_TABLE_ERRNO.has(Number(value?.errno));
97
97
  }
98
+ const RETRY = new Set(['40001', '40P01', '55P03']);
99
+ const RETRY_ERRNO = new Set([1213, 1020, 1205]);
100
+ /**
101
+ * A deadlock, serialization failure or lock wait timeout (PostgreSQL 40001, 40P01, 55P03; MySQL
102
+ * and MariaDB 1213, 1020, 1205): the caller's transaction is over and is run again whole.
103
+ */
104
+ export function isRetryable(error) {
105
+ const value = error;
106
+ return RETRY.has(value?.code) || RETRY_ERRNO.has(Number(value?.errno));
107
+ }
98
108
  /** A number from a driver value: pg bigint strings, mariadb BigInt, mysql2 numbers. */
99
109
  export function num(value) {
100
110
  return value === null || value === undefined ? 0 : Number(value);
package/dist/store.d.ts CHANGED
@@ -25,12 +25,23 @@ export interface Scanned extends SearchHit {
25
25
  }
26
26
  export declare class Store {
27
27
  #private;
28
+ /** MySQL and MariaDB read an older snapshot in a REPEATABLE READ transaction. */
29
+ get snapshots(): boolean;
28
30
  constructor(dialect: Dialect, slots: number);
29
31
  check(db: Session): Promise<void>;
30
32
  fingerprints(db: Session): Promise<Map<string, string>>;
31
33
  saveFingerprint(db: Session, name: string, fingerprint: string, at: number): Promise<void>;
32
34
  /** The rows of one item (the item index). */
33
35
  itemRows(db: Session, index: string, id: string): Promise<StoredRow[]>;
36
+ /**
37
+ * MySQL and MariaDB: the item's rows as committed now (a locking read), for a write whose
38
+ * transaction may read an older snapshot. Called after the write's own inserts: its range then
39
+ * holds the write's own rows, so the read waits for no other writer (an empty range would take a
40
+ * gap lock that two writers of new items share and then both insert into).
41
+ */
42
+ lockedItemRows(db: Session, index: string, id: string): Promise<StoredRow[]>;
43
+ /** MySQL and MariaDB: how many rows the item has as committed now (a locking read, as above). */
44
+ lockedItemCount(db: Session, index: string, id: string): Promise<number>;
34
45
  deleteItem(db: Session, index: string, id: string): Promise<number>;
35
46
  deleteRows(db: Session, index: string, id: string, rows: readonly TermRow[]): Promise<void>;
36
47
  insertRows(db: Session, index: string, id: string, sortKey: number, rows: readonly TermRow[]): Promise<void>;
package/dist/store.js CHANGED
@@ -12,6 +12,10 @@ export const INDEXES_TABLE = 'dokebi_search_indexes';
12
12
  const BATCH = 500;
13
13
  export class Store {
14
14
  #dialect;
15
+ /** MySQL and MariaDB read an older snapshot in a REPEATABLE READ transaction. */
16
+ get snapshots() {
17
+ return this.#dialect !== 'postgres';
18
+ }
15
19
  #slots;
16
20
  constructor(dialect, slots) {
17
21
  this.#dialect = dialect;
@@ -43,6 +47,26 @@ export class Store {
43
47
  sortKey: num(row.sort_key),
44
48
  }));
45
49
  }
50
+ /**
51
+ * MySQL and MariaDB: the item's rows as committed now (a locking read), for a write whose
52
+ * transaction may read an older snapshot. Called after the write's own inserts: its range then
53
+ * holds the write's own rows, so the read waits for no other writer (an empty range would take a
54
+ * gap lock that two writers of new items share and then both insert into).
55
+ */
56
+ async lockedItemRows(db, index, id) {
57
+ const { rows } = await db.query(`SELECT field, term, scope, sort_key FROM ${TERMS_TABLE} WHERE index_name = ? AND item_id = ? FOR UPDATE`, [index, id]);
58
+ return rows.map((row) => ({
59
+ field: String(row.field),
60
+ term: String(row.term),
61
+ scope: String(row.scope),
62
+ sortKey: num(row.sort_key),
63
+ }));
64
+ }
65
+ /** MySQL and MariaDB: how many rows the item has as committed now (a locking read, as above). */
66
+ async lockedItemCount(db, index, id) {
67
+ const { rows } = await db.query(`SELECT COUNT(*) AS n FROM ${TERMS_TABLE} WHERE index_name = ? AND item_id = ? FOR UPDATE`, [index, id]);
68
+ return num(rows[0]?.n);
69
+ }
46
70
  async deleteItem(db, index, id) {
47
71
  const result = await db.query(`DELETE FROM ${TERMS_TABLE} WHERE index_name = ? AND item_id = ?`, [index, id]);
48
72
  return result.affected;
@@ -66,7 +90,10 @@ export class Store {
66
90
  params.push(index, row.field, row.term, row.scope, sortKey, id);
67
91
  return '(?, ?, ?, ?, ?, ?)';
68
92
  });
69
- await db.query(`INSERT INTO ${TERMS_TABLE} (index_name, field, term, scope, sort_key, item_id) VALUES ${tuples.join(', ')}`, params);
93
+ // A row another writer committed after this transaction's snapshot may already be there
94
+ // (MySQL, MariaDB): it is the same row, kept.
95
+ const keep = this.#dialect === 'postgres' ? '' : ' ON DUPLICATE KEY UPDATE item_id = item_id';
96
+ await db.query(`INSERT INTO ${TERMS_TABLE} (index_name, field, term, scope, sort_key, item_id) VALUES ${tuples.join(', ')}${keep}`, params);
70
97
  }
71
98
  }
72
99
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dokebi/search",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Search in an application's lists for Dokebi by @dokebi/db AI.md 6.10: a term table in display order written in the text's transaction, Korean word prefixes, the rarest word leads, bounded requests and searches with a signed cursor.",
5
5
  "keywords": [
6
6
  "dokebi",
@@ -45,7 +45,7 @@
45
45
  "@dokebi/db": "^0.4.1"
46
46
  },
47
47
  "devDependencies": {
48
- "@dokebi/db": "0.4.1"
48
+ "@dokebi/db": "0.4.2"
49
49
  },
50
50
  "dokebi": {
51
51
  "schemaVersion": 2,