@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 +4 -4
- package/dist/plugin.js +48 -11
- package/dist/sql.d.ts +5 -0
- package/dist/sql.js +10 -0
- package/dist/store.d.ts +11 -0
- package/dist/store.js +28 -1
- package/package.json +2 -2
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 }
|
|
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()
|
|
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
|
-
|
|
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
|
-
/**
|
|
245
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
48
|
+
"@dokebi/db": "0.4.2"
|
|
49
49
|
},
|
|
50
50
|
"dokebi": {
|
|
51
51
|
"schemaVersion": 2,
|