@hozon/adapter 0.1.0 → 0.2.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/lib/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  export { AbstractPostgresAdapter, type PostgresTypes } from './postgres.js';
2
2
  export { AbstractSQLiteAdapter, type SQLiteTypes } from './sqlite.js';
3
- export type { AbstractAdapter, Adapter, AdapterTypes, ArrayPresenceMode, ColumnTypes, CreatedAtColumn, Functions, NullOrderingTerm, OrderDirection, UpdatedAtColumn, } from './types.js';
3
+ export type { AbstractAdapter, Adapter, AdapterTypes, ArrayPresenceMode, ColumnTypes, CreatedAtColumn, Functions, NullOrderingTerm, OrderDirection, SearchCapability, SearchEntry, SearchHit, SearchIndex, SearchQuery, UpdatedAtColumn, } from './types.js';
package/lib/postgres.d.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import type { Expression, RawBuilder } from 'kysely';
2
- import type { AbstractAdapter, AdapterTypes, ArrayPresenceMode, ColumnTypes, Functions, NullOrderingTerm, OrderDirection } from './types.js';
2
+ import type { AbstractAdapter, AdapterTypes, ArrayPresenceMode, ColumnTypes, Functions, NullOrderingTerm, OrderDirection, SearchCapability } from './types.js';
3
3
  export type PostgresTypes = {
4
4
  Binary: Uint8Array;
5
5
  JSON: unknown;
6
6
  Timestamp: string;
7
7
  };
8
8
  export declare abstract class AbstractPostgresAdapter<T extends AdapterTypes = PostgresTypes> implements AbstractAdapter<T> {
9
+ search: SearchCapability;
9
10
  kind: "postgres";
10
11
  functions: Functions;
11
12
  types: ColumnTypes;
package/lib/postgres.js CHANGED
@@ -1,5 +1,49 @@
1
1
  import { sql } from 'kysely';
2
+ import { assertSearchFields, assertSearchIndex } from './search.js';
2
3
  export class AbstractPostgresAdapter {
4
+ search = {
5
+ async createIndex (db, params) {
6
+ assertSearchIndex(params.index);
7
+ assertSearchFields(params.fields, params.index);
8
+ await sql`CREATE TABLE IF NOT EXISTS ${sql.table(params.index)} (
9
+ entry_key TEXT PRIMARY KEY REFERENCES ${sql.table(params.table)} (${sql.id(params.key)}) ON DELETE CASCADE,
10
+ search_vector tsvector
11
+ )`.execute(db);
12
+ // Hash the compiled physical name to stay below PostgreSQL's 63-byte limit.
13
+ let hash = 14695981039346656037n;
14
+ for (const char of sql.table(params.index).compile(db).sql){
15
+ hash = BigInt.asUintN(64, (hash ^ BigInt(char.codePointAt(0) ?? 0)) * 1099511628211n);
16
+ }
17
+ const ginIndex = `search_${hash.toString(16)}`;
18
+ await sql`CREATE INDEX IF NOT EXISTS ${sql.id(ginIndex)} ON ${sql.table(params.index)} USING GIN(search_vector)`.execute(db);
19
+ },
20
+ async dropIndex (db, { index }) {
21
+ assertSearchIndex(index);
22
+ await sql`DROP TABLE IF EXISTS ${sql.table(index)} CASCADE`.execute(db);
23
+ },
24
+ async upsertEntry (db, { index, key, fields }) {
25
+ assertSearchIndex(index);
26
+ assertSearchFields(Object.keys(fields), index);
27
+ const vector = sql.join(Object.values(fields).map((value)=>sql`to_tsvector('simple', ${value})`), sql` || `);
28
+ await sql`INSERT INTO ${sql.table(index)} (entry_key, search_vector)
29
+ VALUES (${key}, ${vector})
30
+ ON CONFLICT (entry_key) DO UPDATE SET search_vector = excluded.search_vector
31
+ `.execute(db);
32
+ },
33
+ async removeEntry (db, { index, key }) {
34
+ assertSearchIndex(index);
35
+ await sql`DELETE FROM ${sql.table(index)} WHERE entry_key = ${key}`.execute(db);
36
+ },
37
+ async query (db, { index, query, limit }) {
38
+ assertSearchIndex(index);
39
+ const result = await sql`
40
+ SELECT entry_key AS key, ts_rank(search_vector, plainto_tsquery('simple', ${query})) AS rank
41
+ FROM ${sql.table(index)} WHERE search_vector @@ plainto_tsquery('simple', ${query})
42
+ ORDER BY rank DESC, entry_key LIMIT ${limit}
43
+ `.execute(db);
44
+ return result.rows;
45
+ }
46
+ };
3
47
  kind = 'postgres';
4
48
  functions = {
5
49
  now: 'now()'
@@ -0,0 +1,4 @@
1
+ /** Logical names stay within PostgreSQL's identifier limit without truncation. */
2
+ export declare function assertSearchIndex(index: string): void;
3
+ /** FTS5 reserves internal columns and its table name, case-insensitively. */
4
+ export declare function assertSearchFields(fields: Array<string>, index: string): void;
package/lib/search.js ADDED
@@ -0,0 +1,18 @@
1
+ /** Logical names stay within PostgreSQL's identifier limit without truncation. */ export function assertSearchIndex(index) {
2
+ if (!/^[A-Za-z_][A-Za-z0-9_]{0,62}$/.test(index)) {
3
+ throw new Error(`Invalid search index name: ${JSON.stringify(index)}`);
4
+ }
5
+ }
6
+ /** FTS5 reserves internal columns and its table name, case-insensitively. */ export function assertSearchFields(fields, index) {
7
+ if (fields.length === 0 || fields.length > 499) {
8
+ throw new RangeError('Search requires 1 to 499 fields');
9
+ }
10
+ const seen = new Set();
11
+ for (const field of fields){
12
+ const normalised = field.toLowerCase();
13
+ if (!/^[A-Za-z_][A-Za-z0-9_]{0,62}$/.test(field) || /^(entry_key|rank|rowid)$/i.test(field) || normalised === index.toLowerCase() || seen.has(normalised)) {
14
+ throw new Error(`Invalid search field name: ${JSON.stringify(field)}`);
15
+ }
16
+ seen.add(normalised);
17
+ }
18
+ }
package/lib/sqlite.d.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import type { Expression, RawBuilder } from 'kysely';
2
- import type { AbstractAdapter, AdapterTypes, ArrayPresenceMode, ColumnTypes, Functions, NullOrderingTerm, OrderDirection } from './types.js';
2
+ import type { AbstractAdapter, AdapterTypes, ArrayPresenceMode, ColumnTypes, Functions, NullOrderingTerm, OrderDirection, SearchCapability } from './types.js';
3
3
  export type SQLiteTypes = {
4
4
  Binary: Uint8Array;
5
5
  JSON: string;
6
6
  Timestamp: number;
7
7
  };
8
8
  export declare abstract class AbstractSQLiteAdapter<T extends AdapterTypes = SQLiteTypes> implements AbstractAdapter<T> {
9
+ search: SearchCapability | undefined;
9
10
  kind: "sqlite";
10
11
  functions: Functions;
11
12
  types: ColumnTypes;
package/lib/sqlite.js CHANGED
@@ -1,5 +1,47 @@
1
1
  import { sql } from 'kysely';
2
+ import { assertSearchFields, assertSearchIndex } from './search.js';
2
3
  export class AbstractSQLiteAdapter {
4
+ search = {
5
+ async createIndex (db, params) {
6
+ assertSearchIndex(params.index);
7
+ assertSearchFields(params.fields, params.index);
8
+ const columns = [
9
+ sql`entry_key UNINDEXED`,
10
+ ...params.fields.map((f)=>sql.id(f))
11
+ ];
12
+ await sql`CREATE VIRTUAL TABLE IF NOT EXISTS ${sql.table(params.index)} USING fts5(${sql.join(columns)})`.execute(db);
13
+ },
14
+ async dropIndex (db, { index }) {
15
+ assertSearchIndex(index);
16
+ await sql`DROP TABLE IF EXISTS ${sql.table(index)}`.execute(db);
17
+ },
18
+ async upsertEntry (db, { index, key, fields }) {
19
+ assertSearchIndex(index);
20
+ assertSearchFields(Object.keys(fields), index);
21
+ // Delete and insert share the caller's transaction for atomic replacement.
22
+ await sql`DELETE FROM ${sql.table(index)} WHERE entry_key = ${key}`.execute(db);
23
+ await sql`INSERT INTO ${sql.table(index)} (${sql.join([
24
+ sql.id('entry_key'),
25
+ ...Object.keys(fields).map((f)=>sql.id(f))
26
+ ])}) VALUES (${sql.join([
27
+ key,
28
+ ...Object.values(fields)
29
+ ])})`.execute(db);
30
+ },
31
+ async removeEntry (db, { index, key }) {
32
+ assertSearchIndex(index);
33
+ await sql`DELETE FROM ${sql.table(index)} WHERE entry_key = ${key}`.execute(db);
34
+ },
35
+ async query (db, { index, query, limit }) {
36
+ assertSearchIndex(index);
37
+ const result = await sql`
38
+ SELECT entry_key AS key, rank FROM ${sql.table(index)}
39
+ WHERE ${sql.table(index)} MATCH ${query}
40
+ ORDER BY rank, entry_key LIMIT ${limit}
41
+ `.execute(db);
42
+ return result.rows;
43
+ }
44
+ };
3
45
  kind = 'sqlite';
4
46
  functions = {
5
47
  now: sql`(unixepoch())`
package/lib/types.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ColumnDataType, ColumnType, Dialect, Expression, RawBuilder } from 'kysely';
1
+ import type { ColumnDataType, ColumnType, Dialect, Expression, Kysely, RawBuilder } from 'kysely';
2
2
  export type OrderDirection = 'asc' | 'desc';
3
3
  export type ArrayPresenceMode = 'null' | 'nonNull' | 'empty' | 'nonEmpty' | 'nullOrEmpty';
4
4
  export type NullOrderingTerm = {
@@ -28,8 +28,46 @@ export type AdapterTypes = {
28
28
  JSON: unknown;
29
29
  Timestamp: unknown;
30
30
  };
31
+ /** Keys are text, matching the owning table's referenced column. */
32
+ export type SearchIndex = {
33
+ index: string;
34
+ table: string;
35
+ key: string;
36
+ fields: Array<string>;
37
+ };
38
+ export type SearchEntry = {
39
+ index: string;
40
+ key: string;
41
+ fields: Record<string, string>;
42
+ };
43
+ export type SearchQuery = {
44
+ index: string;
45
+ query: string;
46
+ limit: number;
47
+ };
48
+ export type SearchHit = {
49
+ key: string;
50
+ rank: number;
51
+ };
52
+ /**
53
+ * Logical table names follow the supplied database's prefix rules.
54
+ * Query syntax and rank scales are dialect-specific. No method opens a transaction.
55
+ */
56
+ export type SearchCapability = {
57
+ createIndex<Database>(db: Kysely<Database>, params: SearchIndex): Promise<void>;
58
+ dropIndex<Database>(db: Kysely<Database>, params: {
59
+ index: string;
60
+ }): Promise<void>;
61
+ upsertEntry<Database>(db: Kysely<Database>, params: SearchEntry): Promise<void>;
62
+ removeEntry<Database>(db: Kysely<Database>, params: {
63
+ index: string;
64
+ key: string;
65
+ }): Promise<void>;
66
+ query<Database>(db: Kysely<Database>, params: SearchQuery): Promise<Array<SearchHit>>;
67
+ };
31
68
  export type AbstractAdapter<T extends AdapterTypes = AdapterTypes> = {
32
69
  kind: 'sqlite' | 'postgres';
70
+ search?: SearchCapability;
33
71
  functions: Functions;
34
72
  types: ColumnTypes;
35
73
  encodeBinary(value: Uint8Array): T['Binary'];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hozon/adapter",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Database adapter interfaces and dialect behavior for Hozon",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,11 +17,11 @@
17
17
  "lib/*"
18
18
  ],
19
19
  "dependencies": {
20
- "kysely": "^0.29.6"
20
+ "kysely": "^0.29.7"
21
21
  },
22
22
  "devDependencies": {
23
- "del-cli": "^7.0.0",
24
- "vitest": "5.0.3"
23
+ "del-cli": "^8.0.1",
24
+ "vitest": "5.0.5"
25
25
  },
26
26
  "engines": {
27
27
  "node": ">=24"