@dokebi/search 0.0.0-stage → 0.1.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/sql.d.ts ADDED
@@ -0,0 +1,32 @@
1
+ export type Driver = 'pg' | 'mysql2' | 'mariadb';
2
+ /** The server: PostgreSQL, MySQL or MariaDB (mysql2 can talk to MariaDB too). */
3
+ export type Dialect = 'postgres' | 'mysql' | 'mariadb';
4
+ export type Row = Record<string, unknown>;
5
+ export interface Result {
6
+ readonly rows: Row[];
7
+ readonly affected: number;
8
+ readonly insertId: string | undefined;
9
+ }
10
+ /** A pool, a connection or a transaction client: statements with `?` placeholders. */
11
+ export interface Session {
12
+ query(sql: string, params?: readonly unknown[]): Promise<Result>;
13
+ }
14
+ interface Queryable {
15
+ query(sql: string, values?: unknown[]): Promise<unknown>;
16
+ }
17
+ export declare const isQueryable: (value: unknown) => value is Queryable;
18
+ /** Wraps a client of a known driver (the caller's transaction, the plugin's pool). */
19
+ export declare function session(driver: Driver, client: unknown): Session;
20
+ /** Finds the driver of a pool by the shape of one `SELECT 1` answer (no write). */
21
+ export declare function detectDriver(resource: unknown): Promise<Driver | undefined>;
22
+ /** The server behind a pool: PostgreSQL, or MySQL and MariaDB told apart by VERSION(). */
23
+ export declare function serverDialect(driver: Driver, pool: Session): Promise<{
24
+ dialect: Dialect;
25
+ version: string;
26
+ }>;
27
+ /** A missing table or column (PostgreSQL 42P01, 42703; MySQL and MariaDB 1146, 1054). */
28
+ export declare function isMissingTable(error: unknown): boolean;
29
+ /** A number from a driver value: pg bigint strings, mariadb BigInt, mysql2 numbers. */
30
+ export declare function num(value: unknown): number;
31
+ export declare function text(value: unknown): string | undefined;
32
+ export {};
package/dist/sql.js ADDED
@@ -0,0 +1,104 @@
1
+ import { SearchError } from './errors.js';
2
+ export const isQueryable = (value) => value !== null &&
3
+ typeof value === 'object' &&
4
+ typeof value.query === 'function';
5
+ /** `?` placeholders as `$1, $2, …` for pg; cached, the statements are few. */
6
+ const numberedCache = new Map();
7
+ function numbered(sql) {
8
+ let text = numberedCache.get(sql);
9
+ if (text === undefined) {
10
+ let index = 0;
11
+ text = sql.replace(/\?/g, () => `$${++index}`);
12
+ if (numberedCache.size < 4096)
13
+ numberedCache.set(sql, text);
14
+ }
15
+ return text;
16
+ }
17
+ function idOf(value) {
18
+ if (value === undefined || value === null)
19
+ return undefined;
20
+ const text = String(value);
21
+ return text === '0' ? undefined : text;
22
+ }
23
+ /** Wraps a client of a known driver (the caller's transaction, the plugin's pool). */
24
+ export function session(driver, client) {
25
+ if (!isQueryable(client))
26
+ throw new SearchError('INVALID_ITEM', 'The transaction client has no query method (pass the pg client or the mysql2/mariadb connection of the transaction)');
27
+ if (driver === 'pg')
28
+ return {
29
+ async query(sql, params = []) {
30
+ const result = (await client.query(numbered(sql), [...params]));
31
+ return {
32
+ rows: result.rows ?? [],
33
+ affected: Number(result.rowCount ?? 0),
34
+ insertId: undefined,
35
+ };
36
+ },
37
+ };
38
+ if (driver === 'mysql2')
39
+ return {
40
+ async query(sql, params = []) {
41
+ const [result] = (await client.query(sql, [...params]));
42
+ if (Array.isArray(result))
43
+ return { rows: result, affected: 0, insertId: undefined };
44
+ const header = result;
45
+ return {
46
+ rows: [],
47
+ affected: Number(header.affectedRows ?? 0),
48
+ insertId: idOf(header.insertId),
49
+ };
50
+ },
51
+ };
52
+ return {
53
+ async query(sql, params = []) {
54
+ const result = (await client.query(sql, [...params]));
55
+ if (Array.isArray(result))
56
+ return { rows: [...result], affected: 0, insertId: undefined };
57
+ const header = result;
58
+ return {
59
+ rows: [],
60
+ affected: Number(header.affectedRows ?? 0),
61
+ insertId: idOf(header.insertId),
62
+ };
63
+ },
64
+ };
65
+ }
66
+ /** Finds the driver of a pool by the shape of one `SELECT 1` answer (no write). */
67
+ export async function detectDriver(resource) {
68
+ if (!isQueryable(resource))
69
+ return undefined;
70
+ const probe = await Promise.resolve(resource.query('SELECT 1 AS one'));
71
+ if (probe !== null &&
72
+ typeof probe === 'object' &&
73
+ Array.isArray(probe.rows))
74
+ return 'pg';
75
+ if (Array.isArray(probe) && Array.isArray(probe[0]))
76
+ return 'mysql2';
77
+ if (Array.isArray(probe))
78
+ return 'mariadb';
79
+ return undefined;
80
+ }
81
+ /** The server behind a pool: PostgreSQL, or MySQL and MariaDB told apart by VERSION(). */
82
+ export async function serverDialect(driver, pool) {
83
+ if (driver === 'pg') {
84
+ const { rows } = await pool.query('SHOW server_version');
85
+ return { dialect: 'postgres', version: String(rows[0]?.server_version ?? '') };
86
+ }
87
+ const { rows } = await pool.query('SELECT VERSION() AS version');
88
+ const version = String(rows[0]?.version ?? '');
89
+ return { dialect: /mariadb/i.test(version) ? 'mariadb' : 'mysql', version };
90
+ }
91
+ const MISSING_TABLE = new Set(['42P01', 'ER_NO_SUCH_TABLE', '42703', 'ER_BAD_FIELD_ERROR']);
92
+ const MISSING_TABLE_ERRNO = new Set([1146, 1054]);
93
+ /** A missing table or column (PostgreSQL 42P01, 42703; MySQL and MariaDB 1146, 1054). */
94
+ export function isMissingTable(error) {
95
+ const value = error;
96
+ return MISSING_TABLE.has(value?.code) || MISSING_TABLE_ERRNO.has(Number(value?.errno));
97
+ }
98
+ /** A number from a driver value: pg bigint strings, mariadb BigInt, mysql2 numbers. */
99
+ export function num(value) {
100
+ return value === null || value === undefined ? 0 : Number(value);
101
+ }
102
+ export function text(value) {
103
+ return value === null || value === undefined ? undefined : String(value);
104
+ }
@@ -0,0 +1,66 @@
1
+ import type { SearchHit } from './api.js';
2
+ import type { Dialect, Session } from './sql.js';
3
+ export declare const TERMS_TABLE = "dokebi_search_terms";
4
+ export declare const COUNTS_TABLE = "dokebi_search_counts";
5
+ export declare const INDEXES_TABLE = "dokebi_search_indexes";
6
+ export interface TermRow {
7
+ readonly field: string;
8
+ readonly term: string;
9
+ readonly scope: string;
10
+ }
11
+ export interface StoredRow extends TermRow {
12
+ readonly sortKey: number;
13
+ }
14
+ export interface CountDelta {
15
+ readonly field: string;
16
+ readonly term: string;
17
+ readonly delta: number;
18
+ }
19
+ export interface Position {
20
+ readonly sortKey: number;
21
+ readonly id: string;
22
+ }
23
+ export interface Scanned extends SearchHit {
24
+ readonly hit: boolean;
25
+ }
26
+ export declare class Store {
27
+ #private;
28
+ constructor(dialect: Dialect, slots: number);
29
+ check(db: Session): Promise<void>;
30
+ fingerprints(db: Session): Promise<Map<string, string>>;
31
+ saveFingerprint(db: Session, name: string, fingerprint: string, at: number): Promise<void>;
32
+ /** The rows of one item (the item index). */
33
+ itemRows(db: Session, index: string, id: string): Promise<StoredRow[]>;
34
+ deleteItem(db: Session, index: string, id: string): Promise<number>;
35
+ deleteRows(db: Session, index: string, id: string, rows: readonly TermRow[]): Promise<void>;
36
+ insertRows(db: Session, index: string, id: string, sortKey: number, rows: readonly TermRow[]): Promise<void>;
37
+ /**
38
+ * Moves the item counts of words by their deltas, in one slot chosen at random, in the order
39
+ * given (the caller sorts them, so two writers lock the same rows in the same order).
40
+ */
41
+ addCounts(db: Session, index: string, deltas: readonly CountDelta[]): Promise<void>;
42
+ /** The item count of each word (its slots summed); a word without rows is absent. */
43
+ counts(db: Session, index: string, field: string, terms: readonly string[]): Promise<Map<string, number>>;
44
+ /** One word: the next rows in display order. */
45
+ page(db: Session, input: {
46
+ readonly index: string;
47
+ readonly field: string;
48
+ readonly scope: string;
49
+ readonly term: string;
50
+ readonly after: Position | undefined;
51
+ readonly limit: number;
52
+ }): Promise<SearchHit[]>;
53
+ /**
54
+ * Several words: the next `limit` rows of the leading word in display order, each with
55
+ * whether the item has every other word too (a primary-key lookup per word).
56
+ */
57
+ scan(db: Session, input: {
58
+ readonly index: string;
59
+ readonly field: string;
60
+ readonly scope: string;
61
+ readonly lead: string;
62
+ readonly others: readonly string[];
63
+ readonly after: Position | undefined;
64
+ readonly limit: number;
65
+ }): Promise<Scanned[]>;
66
+ }
package/dist/store.js ADDED
@@ -0,0 +1,137 @@
1
+ import { num } from './sql.js';
2
+ /*
3
+ * The statements of the search tables (D-0170 §5, @dokebi/db AI.md 6.10). Every read is an
4
+ * equality on the leading columns of the primary key followed by its order, read backwards:
5
+ * no sort, no scan beyond the rows asked for. The cursor condition is one row comparison on
6
+ * PostgreSQL and the expanded form on MySQL and MariaDB (db AI.md 7.4).
7
+ */
8
+ export const TERMS_TABLE = 'dokebi_search_terms';
9
+ export const COUNTS_TABLE = 'dokebi_search_counts';
10
+ export const INDEXES_TABLE = 'dokebi_search_indexes';
11
+ /** Rows of one multi-row INSERT or DELETE (parameters stay far below every driver's limit). */
12
+ const BATCH = 500;
13
+ export class Store {
14
+ #dialect;
15
+ #slots;
16
+ constructor(dialect, slots) {
17
+ this.#dialect = dialect;
18
+ this.#slots = slots;
19
+ }
20
+ async check(db) {
21
+ await db.query(`SELECT index_name, field, term, scope, sort_key, item_id FROM ${TERMS_TABLE} WHERE 1 = 0`);
22
+ await db.query(`SELECT index_name, field, term, slot, count FROM ${COUNTS_TABLE} WHERE 1 = 0`);
23
+ await db.query(`SELECT name, fingerprint, updated_at FROM ${INDEXES_TABLE} WHERE 1 = 0`);
24
+ }
25
+ async fingerprints(db) {
26
+ const { rows } = await db.query(`SELECT name, fingerprint FROM ${INDEXES_TABLE}`);
27
+ return new Map(rows.map((row) => [String(row.name), String(row.fingerprint)]));
28
+ }
29
+ async saveFingerprint(db, name, fingerprint, at) {
30
+ const insert = `INSERT INTO ${INDEXES_TABLE} (name, fingerprint, updated_at) VALUES (?, ?, ?)`;
31
+ if (this.#dialect === 'postgres')
32
+ await db.query(`${insert} ON CONFLICT (name) DO UPDATE SET fingerprint = EXCLUDED.fingerprint, updated_at = EXCLUDED.updated_at`, [name, fingerprint, at]);
33
+ else
34
+ await db.query(`${insert} ON DUPLICATE KEY UPDATE fingerprint = VALUES(fingerprint), updated_at = VALUES(updated_at)`, [name, fingerprint, at]);
35
+ }
36
+ /** The rows of one item (the item index). */
37
+ async itemRows(db, index, id) {
38
+ const { rows } = await db.query(`SELECT field, term, scope, sort_key FROM ${TERMS_TABLE} WHERE index_name = ? AND item_id = ?`, [index, id]);
39
+ return rows.map((row) => ({
40
+ field: String(row.field),
41
+ term: String(row.term),
42
+ scope: String(row.scope),
43
+ sortKey: num(row.sort_key),
44
+ }));
45
+ }
46
+ async deleteItem(db, index, id) {
47
+ const result = await db.query(`DELETE FROM ${TERMS_TABLE} WHERE index_name = ? AND item_id = ?`, [index, id]);
48
+ return result.affected;
49
+ }
50
+ async deleteRows(db, index, id, rows) {
51
+ for (let start = 0; start < rows.length; start += BATCH) {
52
+ const part = rows.slice(start, start + BATCH);
53
+ const params = [index, id];
54
+ const tuples = part.map((row) => {
55
+ params.push(row.field, row.term, row.scope);
56
+ return '(?, ?, ?)';
57
+ });
58
+ await db.query(`DELETE FROM ${TERMS_TABLE} WHERE index_name = ? AND item_id = ? AND (field, term, scope) IN (${tuples.join(', ')})`, params);
59
+ }
60
+ }
61
+ async insertRows(db, index, id, sortKey, rows) {
62
+ for (let start = 0; start < rows.length; start += BATCH) {
63
+ const part = rows.slice(start, start + BATCH);
64
+ const params = [];
65
+ const tuples = part.map((row) => {
66
+ params.push(index, row.field, row.term, row.scope, sortKey, id);
67
+ return '(?, ?, ?, ?, ?, ?)';
68
+ });
69
+ await db.query(`INSERT INTO ${TERMS_TABLE} (index_name, field, term, scope, sort_key, item_id) VALUES ${tuples.join(', ')}`, params);
70
+ }
71
+ }
72
+ /**
73
+ * Moves the item counts of words by their deltas, in one slot chosen at random, in the order
74
+ * given (the caller sorts them, so two writers lock the same rows in the same order).
75
+ */
76
+ async addCounts(db, index, deltas) {
77
+ const slot = Math.floor(Math.random() * this.#slots);
78
+ for (let start = 0; start < deltas.length; start += BATCH) {
79
+ const part = deltas.slice(start, start + BATCH);
80
+ const params = [];
81
+ const tuples = part.map((row) => {
82
+ params.push(index, row.field, row.term, slot, row.delta);
83
+ return '(?, ?, ?, ?, ?)';
84
+ });
85
+ const insert = `INSERT INTO ${COUNTS_TABLE} (index_name, field, term, slot, count) VALUES ${tuples.join(', ')}`;
86
+ const upsert = this.#dialect === 'postgres'
87
+ ? `${insert} ON CONFLICT (index_name, field, term, slot) DO UPDATE SET count = ${COUNTS_TABLE}.count + EXCLUDED.count`
88
+ : this.#dialect === 'mysql'
89
+ ? `${insert} AS added ON DUPLICATE KEY UPDATE count = ${COUNTS_TABLE}.count + added.count`
90
+ : `${insert} ON DUPLICATE KEY UPDATE count = count + VALUES(count)`;
91
+ await db.query(upsert, params);
92
+ }
93
+ }
94
+ /** The item count of each word (its slots summed); a word without rows is absent. */
95
+ async counts(db, index, field, terms) {
96
+ const { rows } = await db.query(`SELECT term, SUM(count) AS n FROM ${COUNTS_TABLE} WHERE index_name = ? AND field = ? AND term IN (${terms.map(() => '?').join(', ')}) GROUP BY term`, [index, field, ...terms]);
97
+ return new Map(rows.map((row) => [String(row.term), num(row.n)]));
98
+ }
99
+ #after(alias, after, params) {
100
+ if (!after)
101
+ return '';
102
+ if (this.#dialect === 'postgres') {
103
+ params.push(after.sortKey, after.id);
104
+ return ` AND (${alias}sort_key, ${alias}item_id) < (?, ?)`;
105
+ }
106
+ params.push(after.sortKey, after.sortKey, after.id);
107
+ return ` AND (${alias}sort_key < ? OR (${alias}sort_key = ? AND ${alias}item_id < ?))`;
108
+ }
109
+ /** One word: the next rows in display order. */
110
+ async page(db, input) {
111
+ const params = [input.index, input.field, input.term, input.scope];
112
+ const after = this.#after('', input.after, params);
113
+ params.push(input.limit);
114
+ const { rows } = await db.query(`SELECT sort_key, item_id FROM ${TERMS_TABLE} WHERE index_name = ? AND field = ? AND term = ? AND scope = ?${after} ORDER BY sort_key DESC, item_id DESC LIMIT ?`, params);
115
+ return rows.map((row) => ({ id: String(row.item_id), sortKey: num(row.sort_key) }));
116
+ }
117
+ /**
118
+ * Several words: the next `limit` rows of the leading word in display order, each with
119
+ * whether the item has every other word too (a primary-key lookup per word).
120
+ */
121
+ async scan(db, input) {
122
+ const params = [];
123
+ const checks = input.others.map((term) => {
124
+ params.push(input.index, input.field, term, input.scope);
125
+ return `EXISTS (SELECT 1 FROM ${TERMS_TABLE} o WHERE o.index_name = ? AND o.field = ? AND o.term = ? AND o.scope = ? AND o.sort_key = t.sort_key AND o.item_id = t.item_id)`;
126
+ });
127
+ params.push(input.index, input.field, input.lead, input.scope);
128
+ const after = this.#after('t.', input.after, params);
129
+ params.push(input.limit);
130
+ const { rows } = await db.query(`SELECT t.sort_key, t.item_id, CASE WHEN ${checks.join(' AND ')} THEN 1 ELSE 0 END AS hit FROM ${TERMS_TABLE} t WHERE t.index_name = ? AND t.field = ? AND t.term = ? AND t.scope = ?${after} ORDER BY t.sort_key DESC, t.item_id DESC LIMIT ?`, params);
131
+ return rows.map((row) => ({
132
+ id: String(row.item_id),
133
+ sortKey: num(row.sort_key),
134
+ hit: num(row.hit) === 1,
135
+ }));
136
+ }
137
+ }
@@ -0,0 +1,31 @@
1
+ /** The longest term, in code points (the column is varchar(64)). */
2
+ export declare const MAX_TERM = 64;
3
+ /** The longest prefix a field may declare. */
4
+ export declare const MAX_PREFIX = 32;
5
+ /** The most words one query may have. */
6
+ export declare const MAX_QUERY_WORDS = 8;
7
+ /**
8
+ * The version of these rules, part of every index fingerprint: a change of the normalisation
9
+ * marks every index stale, so the application indexes its rows again.
10
+ */
11
+ export declare const TERMS_VERSION = 1;
12
+ export interface TermRules {
13
+ /** The longest word prefix indexed; 0 indexes whole words only. */
14
+ readonly prefix: number;
15
+ /** The shortest prefix indexed (whole words are always indexed). */
16
+ readonly minLength: number;
17
+ }
18
+ /** The normalised words of a text, in order, repeats kept. */
19
+ export declare function words(text: string): string[];
20
+ /** The terms of a text under `rules`: every word (cut to MAX_TERM) and its prefixes. */
21
+ export declare function terms(text: string, rules: TermRules): Set<string>;
22
+ /**
23
+ * The terms a query looks up: its distinct words, each a term the index holds for every text
24
+ * that has the word or a word starting with it (up to `prefix`); a longer word matches whole
25
+ * words only, so a search never matches more than it says. Words shorter than `minLength` are
26
+ * returned in `short` (the index has them only as whole words: the caller refuses the query).
27
+ */
28
+ export declare function queryTerms(text: string, rules: TermRules): {
29
+ readonly terms: string[];
30
+ readonly short: string[];
31
+ };
package/dist/terms.js ADDED
@@ -0,0 +1,58 @@
1
+ /*
2
+ * The terms of a text (D-0170, @dokebi/db AI.md 6.10): NFKC, lower case, words split on anything
3
+ * that is not a letter, mark or digit, and each word with its prefixes from `minLength` to
4
+ * `prefix` code points (a Hangul syllable is one), so a search for a stem finds every form the
5
+ * language attaches endings to. The number of words is never cut (6.1). Pure functions: the
6
+ * browser may use them to show what will match.
7
+ */
8
+ /** The longest term, in code points (the column is varchar(64)). */
9
+ export const MAX_TERM = 64;
10
+ /** The longest prefix a field may declare. */
11
+ export const MAX_PREFIX = 32;
12
+ /** The most words one query may have. */
13
+ export const MAX_QUERY_WORDS = 8;
14
+ /**
15
+ * The version of these rules, part of every index fingerprint: a change of the normalisation
16
+ * marks every index stale, so the application indexes its rows again.
17
+ */
18
+ export const TERMS_VERSION = 1;
19
+ const SEPARATOR = /[^\p{L}\p{M}\p{N}]+/u;
20
+ /** The normalised words of a text, in order, repeats kept. */
21
+ export function words(text) {
22
+ const out = [];
23
+ for (const word of text.normalize('NFKC').toLowerCase().split(SEPARATOR))
24
+ if (word)
25
+ out.push(word);
26
+ return out;
27
+ }
28
+ /** The terms of a text under `rules`: every word (cut to MAX_TERM) and its prefixes. */
29
+ export function terms(text, rules) {
30
+ const out = new Set();
31
+ const longest = Math.min(rules.prefix, MAX_TERM);
32
+ for (const word of words(text)) {
33
+ const points = Array.from(word);
34
+ out.add(points.length > MAX_TERM ? points.slice(0, MAX_TERM).join('') : word);
35
+ const end = Math.min(longest, points.length - 1);
36
+ for (let length = rules.minLength; length <= end; length++)
37
+ out.add(points.slice(0, length).join(''));
38
+ }
39
+ return out;
40
+ }
41
+ /**
42
+ * The terms a query looks up: its distinct words, each a term the index holds for every text
43
+ * that has the word or a word starting with it (up to `prefix`); a longer word matches whole
44
+ * words only, so a search never matches more than it says. Words shorter than `minLength` are
45
+ * returned in `short` (the index has them only as whole words: the caller refuses the query).
46
+ */
47
+ export function queryTerms(text, rules) {
48
+ const seen = new Set();
49
+ const short = [];
50
+ for (const word of words(text)) {
51
+ const points = Array.from(word);
52
+ const term = points.length > MAX_TERM ? points.slice(0, MAX_TERM).join('') : word;
53
+ if (points.length < rules.minLength && rules.prefix > 0)
54
+ short.push(term);
55
+ seen.add(term);
56
+ }
57
+ return { terms: [...seen], short };
58
+ }
@@ -0,0 +1,95 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "additionalProperties": false,
5
+ "properties": {
6
+ "connection": {
7
+ "type": "string",
8
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$",
9
+ "default": "main",
10
+ "description": "The dokebi-db connection of the database that holds the application's rows (the terms share their transaction); dokebi add writes the table migration into its migrations folder."
11
+ },
12
+ "pool": {
13
+ "type": "object",
14
+ "additionalProperties": false,
15
+ "properties": {
16
+ "max": {
17
+ "type": "integer",
18
+ "minimum": 0,
19
+ "maximum": 64,
20
+ "default": 4,
21
+ "description": "Connections of the pool derived from the connection for find, so searches never take the request pool. 0 uses the connection pool."
22
+ }
23
+ },
24
+ "default": {}
25
+ },
26
+ "examineLimit": {
27
+ "type": "integer",
28
+ "minimum": 100,
29
+ "maximum": 100000,
30
+ "default": 2000,
31
+ "description": "Most rows of the leading word one find call examines when a search has several words; reached before the page is full, it answers the matches found with next and partial: true."
32
+ },
33
+ "searchLimit": {
34
+ "type": "integer",
35
+ "minimum": 100,
36
+ "maximum": 10000000,
37
+ "default": 50000,
38
+ "description": "Most rows one search of several words examines over all its pages (kept in the signed cursor); reached, it ends with stopped: SEARCH_TOO_BROAD. At least examineLimit."
39
+ },
40
+ "list": {
41
+ "type": "object",
42
+ "additionalProperties": false,
43
+ "properties": {
44
+ "maxLimit": {
45
+ "type": "integer",
46
+ "minimum": 1,
47
+ "maximum": 1000,
48
+ "default": 100,
49
+ "description": "Most items one find call returns."
50
+ }
51
+ },
52
+ "default": {}
53
+ },
54
+ "counts": {
55
+ "type": "object",
56
+ "additionalProperties": false,
57
+ "properties": {
58
+ "slots": {
59
+ "type": "integer",
60
+ "minimum": 1,
61
+ "maximum": 64,
62
+ "default": 4,
63
+ "description": "Rows a word's item count is split into (a flush adds to one at random, a read sums them), so the workers' flushes of common words do not wait on one row."
64
+ },
65
+ "flushMs": {
66
+ "type": "integer",
67
+ "minimum": 100,
68
+ "maximum": 60000,
69
+ "default": 1000,
70
+ "description": "How often each worker adds its buffered word counts to the table, outside every transaction (the counts only choose a search's leading word); also at drain and when 2000 words are buffered."
71
+ }
72
+ },
73
+ "default": {}
74
+ },
75
+ "secrets": {
76
+ "type": "array",
77
+ "items": {
78
+ "type": "string",
79
+ "minLength": 32,
80
+ "maxLength": 512,
81
+ "writeOnly": true
82
+ },
83
+ "minItems": 1,
84
+ "maxItems": 8,
85
+ "uniqueItems": true,
86
+ "description": "Cursor signing secrets (32+ characters); the first signs, all verify. dokebi add writes a generated one (npx dokebi config secret dokebi-search.secrets makes one later); keep dokebi.yaml out of version control"
87
+ },
88
+ "secretsEnv": {
89
+ "type": "string",
90
+ "pattern": "^[A-Za-z_][A-Za-z0-9_]{0,127}$",
91
+ "x-dokebi-secret": false,
92
+ "description": "Environment variable that, when set and not empty, replaces secrets (comma-separated, the first signs): an optional production override (default DOKEBI_SEARCH_SECRETS)"
93
+ }
94
+ }
95
+ }
package/package.json CHANGED
@@ -1,6 +1,88 @@
1
1
  {
2
2
  "name": "@dokebi/search",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
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
+ "keywords": [
6
+ "dokebi",
7
+ "dokebi-plugin",
8
+ "search",
9
+ "full-text",
10
+ "database"
11
+ ],
12
+ "private": false,
13
+ "type": "module",
14
+ "license": "MIT",
15
+ "main": "./dist/index.js",
16
+ "types": "./dist/index.d.ts",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "import": "./dist/index.js"
21
+ },
22
+ "./dependency": {
23
+ "types": "./dist/dependency.d.ts",
24
+ "import": "./dist/dependency.js"
25
+ }
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "templates",
30
+ "options.schema.json",
31
+ "README.md",
32
+ "AI.md",
33
+ "LICENSE"
34
+ ],
35
+ "engines": {
36
+ "node": ">=24.0.0"
37
+ },
38
+ "scripts": {
39
+ "build": "tsc -p tsconfig.json && node -e \"import('./dist/options.js').then(async (m) => { const { renderOptionsSchema } = await import('@dokebi/core'); process.stdout.write(renderOptionsSchema(m.options)); })\" > options.schema.json",
40
+ "typecheck": "tsc -p tsconfig.test.json --noEmit",
41
+ "test": "node -e \"require('node:fs').rmSync('.tmp/tests',{recursive:true,force:true})\" && tsc -p tsconfig.test.json && node --test --test-concurrency=1 \".tmp/tests/tests/*.test.js\""
42
+ },
43
+ "peerDependencies": {
44
+ "@dokebi/core": "^0.4.2",
45
+ "@dokebi/db": "^0.4.1"
46
+ },
47
+ "devDependencies": {
48
+ "@dokebi/db": "0.4.1"
49
+ },
50
+ "dokebi": {
51
+ "schemaVersion": 2,
52
+ "id": "dokebi-search",
53
+ "core": "^0.4.2",
54
+ "dependencies": [
55
+ {
56
+ "name": "dokebi-db",
57
+ "version": "^0.4.1",
58
+ "package": "@dokebi/db"
59
+ }
60
+ ],
61
+ "contributes": [
62
+ "core:describe"
63
+ ],
64
+ "installModule": "dist/install.js",
65
+ "optionsSchema": "options.schema.json",
66
+ "documentation": "AI.md"
67
+ },
68
+ "dokebiCatalog": {
69
+ "kind": "plugin",
70
+ "summary": "Search in your lists that stays bounded on a growing table: a term table in display order written in the text's transaction, Korean word prefixes, the rarest word leads, capped requests and searches with a signed cursor; answers item ids",
71
+ "maturity": "experimental",
72
+ "provides": [
73
+ "search"
74
+ ],
75
+ "doesNot": [
76
+ "No MongoDB (PostgreSQL, MySQL 8, MariaDB 10.6+)",
77
+ "No relevance ranking: results come in the index's display order",
78
+ "Does not read or authorize your rows: it answers item ids, and hidden items are removed from the index by your code",
79
+ "No typo tolerance, synonyms or stemming beyond word prefixes",
80
+ "Does not create tables at start (the migration dokebi add writes)"
81
+ ],
82
+ "recipes": []
83
+ },
84
+ "publishConfig": {
85
+ "access": "public",
86
+ "registry": "https://registry.npmjs.org/"
87
+ }
88
+ }
@@ -0,0 +1,10 @@
1
+ // The search indexes of this application (@dokebi/search, D-0170): each index has the fields a
2
+ // list searches, and each field its term rules. `prefix` is the longest word prefix indexed
3
+ // (default 10, so a search for a stem finds the words that start with it, Korean particles
4
+ // included); 0 indexes whole words only. Replace the example with your own lists. After changing
5
+ // a field's rules, index its rows again and call search.markCurrent(<index>).
6
+ import { defineSearchIndexes } from '@dokebi/search';
7
+
8
+ export default defineSearchIndexes({
9
+ posts: { fields: { title: {}, body: {}, author: { prefix: 0 } } },
10
+ });