@enbox/dwn-sql-store 0.0.9 → 0.0.11

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.
Files changed (61) hide show
  1. package/LICENSE +3 -2
  2. package/dist/esm/src/data-store-s3.js +12 -21
  3. package/dist/esm/src/data-store-s3.js.map +1 -1
  4. package/dist/esm/src/data-store-sql.js +14 -37
  5. package/dist/esm/src/data-store-sql.js.map +1 -1
  6. package/dist/esm/src/main.js +1 -0
  7. package/dist/esm/src/main.js.map +1 -1
  8. package/dist/esm/src/message-store-sql.js +13 -107
  9. package/dist/esm/src/message-store-sql.js.map +1 -1
  10. package/dist/esm/src/migration-provider.js +36 -0
  11. package/dist/esm/src/migration-provider.js.map +1 -0
  12. package/dist/esm/src/migration-runner.js +26 -88
  13. package/dist/esm/src/migration-runner.js.map +1 -1
  14. package/dist/esm/src/migrations/001-initial-schema.js +4 -5
  15. package/dist/esm/src/migrations/001-initial-schema.js.map +1 -1
  16. package/dist/esm/src/migrations/002-content-addressed-datastore.js +3 -4
  17. package/dist/esm/src/migrations/002-content-addressed-datastore.js.map +1 -1
  18. package/dist/esm/src/migrations/003-add-squash-column.js +3 -4
  19. package/dist/esm/src/migrations/003-add-squash-column.js.map +1 -1
  20. package/dist/esm/src/migrations/index.js +14 -6
  21. package/dist/esm/src/migrations/index.js.map +1 -1
  22. package/dist/esm/src/resumable-task-store-sql.js +14 -21
  23. package/dist/esm/src/resumable-task-store-sql.js.map +1 -1
  24. package/dist/esm/src/state-index-sql.js +17 -58
  25. package/dist/esm/src/state-index-sql.js.map +1 -1
  26. package/dist/esm/src/utils/filter.js +32 -1
  27. package/dist/esm/src/utils/filter.js.map +1 -1
  28. package/dist/types/src/data-store-s3.d.ts.map +1 -1
  29. package/dist/types/src/data-store-sql.d.ts.map +1 -1
  30. package/dist/types/src/main.d.ts +1 -0
  31. package/dist/types/src/main.d.ts.map +1 -1
  32. package/dist/types/src/message-store-sql.d.ts +0 -8
  33. package/dist/types/src/message-store-sql.d.ts.map +1 -1
  34. package/dist/types/src/migration-provider.d.ts +36 -0
  35. package/dist/types/src/migration-provider.d.ts.map +1 -0
  36. package/dist/types/src/migration-runner.d.ts +13 -39
  37. package/dist/types/src/migration-runner.d.ts.map +1 -1
  38. package/dist/types/src/migrations/001-initial-schema.d.ts +3 -3
  39. package/dist/types/src/migrations/001-initial-schema.d.ts.map +1 -1
  40. package/dist/types/src/migrations/002-content-addressed-datastore.d.ts +2 -2
  41. package/dist/types/src/migrations/002-content-addressed-datastore.d.ts.map +1 -1
  42. package/dist/types/src/migrations/003-add-squash-column.d.ts +2 -2
  43. package/dist/types/src/migrations/003-add-squash-column.d.ts.map +1 -1
  44. package/dist/types/src/migrations/index.d.ts +12 -4
  45. package/dist/types/src/migrations/index.d.ts.map +1 -1
  46. package/dist/types/src/resumable-task-store-sql.d.ts.map +1 -1
  47. package/dist/types/src/state-index-sql.d.ts.map +1 -1
  48. package/package.json +2 -2
  49. package/src/data-store-s3.ts +14 -25
  50. package/src/data-store-sql.ts +15 -44
  51. package/src/main.ts +1 -0
  52. package/src/message-store-sql.ts +14 -113
  53. package/src/migration-provider.ts +52 -0
  54. package/src/migration-runner.ts +33 -123
  55. package/src/migrations/001-initial-schema.ts +6 -7
  56. package/src/migrations/002-content-addressed-datastore.ts +5 -6
  57. package/src/migrations/003-add-squash-column.ts +5 -7
  58. package/src/migrations/index.ts +15 -7
  59. package/src/resumable-task-store-sql.ts +16 -25
  60. package/src/state-index-sql.ts +18 -62
  61. package/src/utils/filter.ts +35 -1
@@ -1,6 +1,6 @@
1
1
  import type { Dialect } from '../dialect/dialect.js';
2
- import type { Kysely } from 'kysely';
3
- import type { Migration } from '../migration-runner.js';
2
+ import type { DwnMigrationFactory } from '../migration-provider.js';
3
+ import type { Kysely, Migration } from 'kysely';
4
4
 
5
5
  import { sql } from 'kysely';
6
6
 
@@ -8,13 +8,12 @@ import { sql } from 'kysely';
8
8
  * Baseline migration: captures the schema as of the pre-migration era.
9
9
  *
10
10
  * For existing databases that already have these tables, this migration is
11
- * detected as "already applied" during the adoption bootstrap (see MigrationRunner).
11
+ * detected as "already applied" during the adoption bootstrap (see runDwnStoreMigrations).
12
12
  * For new databases, this creates the full initial schema.
13
13
  */
14
- export const migration001InitialSchema: Migration = {
15
- name: '001-initial-schema',
14
+ export const migration001InitialSchema: DwnMigrationFactory = (dialect: Dialect): Migration => ({
16
15
 
17
- async up(db: Kysely<any>, dialect: Dialect): Promise<void> {
16
+ async up(db: Kysely<any>): Promise<void> {
18
17
 
19
18
  // ─── messageStoreMessages ───────────────────────────────────────────
20
19
  if (!(await dialect.hasTable(db, 'messageStoreMessages'))) {
@@ -187,4 +186,4 @@ export const migration001InitialSchema: Migration = {
187
186
  .on('stateIndexMeta').columns(['tenant', 'messageCid']).execute();
188
187
  }
189
188
  },
190
- };
189
+ });
@@ -1,6 +1,6 @@
1
1
  import type { Dialect } from '../dialect/dialect.js';
2
- import type { Kysely } from 'kysely';
3
- import type { Migration } from '../migration-runner.js';
2
+ import type { DwnMigrationFactory } from '../migration-provider.js';
3
+ import type { Kysely, Migration } from 'kysely';
4
4
 
5
5
  import { sql } from 'kysely';
6
6
 
@@ -29,10 +29,9 @@ import { sql } from 'kysely';
29
29
  * NOTE: For large databases, the data migration may take significant time.
30
30
  * The migration runs in a single transaction for atomicity.
31
31
  */
32
- export const migration002ContentAddressedDatastore: Migration = {
33
- name: '002-content-addressed-datastore',
32
+ export const migration002ContentAddressedDatastore: DwnMigrationFactory = (dialect: Dialect): Migration => ({
34
33
 
35
- async up(db: Kysely<any>, dialect: Dialect): Promise<void> {
34
+ async up(db: Kysely<any>): Promise<void> {
36
35
 
37
36
  // ─── Create dataRefs table ──────────────────────────────────────────
38
37
  if (!(await dialect.hasTable(db, 'dataRefs'))) {
@@ -137,4 +136,4 @@ export const migration002ContentAddressedDatastore: Migration = {
137
136
  await db.schema.dropTable('dataStore').execute();
138
137
  }
139
138
  },
140
- };
139
+ });
@@ -1,6 +1,5 @@
1
- import type { Dialect } from '../dialect/dialect.js';
2
- import type { Kysely } from 'kysely';
3
- import type { Migration } from '../migration-runner.js';
1
+ import type { DwnMigrationFactory } from '../migration-provider.js';
2
+ import type { Kysely, Migration } from 'kysely';
4
3
 
5
4
  /**
6
5
  * Migration 003: Add `squash` boolean column to `messageStoreMessages`.
@@ -9,13 +8,12 @@ import type { Migration } from '../migration-runner.js';
9
8
  * introduced in the DWN spec. It follows the same pattern as `published`
10
9
  * and `prune` — a nullable boolean column used for query filtering.
11
10
  */
12
- export const migration003AddSquashColumn: Migration = {
13
- name: '003-add-squash-column',
11
+ export const migration003AddSquashColumn: DwnMigrationFactory = (): Migration => ({
14
12
 
15
- async up(db: Kysely<any>, _dialect: Dialect): Promise<void> {
13
+ async up(db: Kysely<any>): Promise<void> {
16
14
  await db.schema
17
15
  .alterTable('messageStoreMessages')
18
16
  .addColumn('squash', 'boolean')
19
17
  .execute();
20
18
  },
21
- };
19
+ });
@@ -1,15 +1,23 @@
1
- import type { Migration } from '../migration-runner.js';
1
+ import type { DwnMigrationFactory } from '../migration-provider.js';
2
2
 
3
3
  import { migration001InitialSchema } from './001-initial-schema.js';
4
4
  import { migration002ContentAddressedDatastore } from './002-content-addressed-datastore.js';
5
5
  import { migration003AddSquashColumn } from './003-add-squash-column.js';
6
6
 
7
7
  /**
8
- * All migrations in sequential order. The MigrationRunner applies them
9
- * in array order, skipping any that have already been recorded.
8
+ * All DWN store migrations in sequential order.
9
+ *
10
+ * Each entry is a `[name, factory]` tuple where the factory is a
11
+ * {@link DwnMigrationFactory} that receives the dialect and returns a
12
+ * standard Kysely `Migration`. The `DwnMigrationProvider` resolves these
13
+ * into the `Record<string, Migration>` that Kysely's `Migrator` expects.
14
+ *
15
+ * **Ordering contract:** Entries MUST be sorted by name (lexicographic).
16
+ * Kysely's `Migrator` sorts by key as well, but maintaining order here
17
+ * makes the source of truth explicit and human-readable.
10
18
  */
11
- export const allMigrations: Migration[] = [
12
- migration001InitialSchema,
13
- migration002ContentAddressedDatastore,
14
- migration003AddSquashColumn,
19
+ export const allDwnMigrations: ReadonlyArray<readonly [name: string, factory: DwnMigrationFactory]> = [
20
+ ['001-initial-schema', migration001InitialSchema],
21
+ ['002-content-addressed-datastore', migration002ContentAddressedDatastore],
22
+ ['003-add-squash-column', migration003AddSquashColumn],
15
23
  ];
@@ -4,7 +4,7 @@ import type { ManagedResumableTask, ResumableTaskStore } from '@enbox/dwn-sdk-js
4
4
 
5
5
  import { Cid } from '@enbox/dwn-sdk-js';
6
6
  import { executeWithTransaction } from './utils/transaction.js';
7
- import { Kysely } from 'kysely';
7
+ import { Kysely, sql } from 'kysely';
8
8
 
9
9
  export class ResumableTaskStoreSql implements ResumableTaskStore {
10
10
  private static readonly taskTimeoutInSeconds = 60;
@@ -23,31 +23,22 @@ export class ResumableTaskStoreSql implements ResumableTaskStore {
23
23
 
24
24
  this.#db = new Kysely<DwnDatabaseType>({ dialect: this.#dialect });
25
25
 
26
- // if table already exists, there is no more things todo
27
- const tableName = 'resumableTasks';
28
- const tableExists = await this.#dialect.hasTable(this.#db, tableName);
29
- if (tableExists) {
30
- return;
31
- }
32
-
33
- // else create the table and corresponding indexes
34
-
35
- const table = this.#db.schema
36
- .createTable(tableName)
37
- .ifNotExists() // kept to show supported by all dialects in contrast to ifNotExists() below, though not needed due to hasTable() check above
38
- .addColumn('id', 'varchar(255)', (col) => col.primaryKey())
39
- .addColumn('task', 'text')
40
- .addColumn('timeout', 'bigint')
41
- .addColumn('retryCount', 'integer');
42
-
43
- await table.execute();
26
+ // Fail fast if migrations have not been run tables must already exist.
27
+ await this.#assertTablesExist();
28
+ }
44
29
 
45
- await this.#db.schema
46
- .createIndex('index_timeout')
47
- // .ifNotExists() // intentionally kept commented out code to show that it is not supported by all dialects (ie. MySQL)
48
- .on('resumableTasks')
49
- .column('timeout')
50
- .execute();
30
+ /**
31
+ * Verifies that the required tables exist by executing a zero-row SELECT.
32
+ * Throws a clear error directing the caller to run migrations first.
33
+ */
34
+ async #assertTablesExist(): Promise<void> {
35
+ try {
36
+ await sql`SELECT 1 FROM ${sql.table('resumableTasks')} LIMIT 0`.execute(this.#db!);
37
+ } catch {
38
+ throw new Error(
39
+ 'ResumableTaskStoreSql: table \'resumableTasks\' does not exist. Run DWN store migrations before opening stores.'
40
+ );
41
+ }
51
42
  }
52
43
 
53
44
  async close(): Promise<void> {
@@ -16,9 +16,9 @@ import type { KeyValues } from '@enbox/dwn-sdk-js';
16
16
  import type { StateIndex } from '@enbox/dwn-sdk-js';
17
17
 
18
18
  import { initDefaultHashes } from '@enbox/dwn-sdk-js';
19
- import { Kysely } from 'kysely';
20
19
  import { SMTStoreSql } from './smt-store-sql.js';
21
20
  import { SparseMerkleTree } from '@enbox/dwn-sdk-js';
21
+ import { Kysely, sql } from 'kysely';
22
22
 
23
23
  export class StateIndexSql implements StateIndex {
24
24
  #dialect: Dialect;
@@ -51,68 +51,24 @@ export class StateIndexSql implements StateIndex {
51
51
  // Ensure default hashes are initialized for the SMT
52
52
  await initDefaultHashes();
53
53
 
54
- // ─── Create stateIndexNodes table ─────────────────────────────────────
55
- const nodesTableName = 'stateIndexNodes';
56
- const nodesTableExists = await this.#dialect.hasTable(this.#db, nodesTableName);
57
- if (!nodesTableExists) {
58
- await this.#db.schema
59
- .createTable(nodesTableName)
60
- .ifNotExists()
61
- .addColumn('tenant', 'varchar(255)', (col) => col.notNull())
62
- .addColumn('scope', 'varchar(200)', (col) => col.notNull())
63
- .addColumn('nodeHash', 'varchar(64)', (col) => col.notNull())
64
- .addColumn('nodeType', 'varchar(10)', (col) => col.notNull())
65
- .addColumn('leftHash', 'varchar(64)')
66
- .addColumn('rightHash', 'varchar(64)')
67
- .addColumn('leafKeyHash', 'varchar(64)')
68
- .addColumn('leafValueCid', 'varchar(60)')
69
- .execute();
70
-
71
- // Not UNIQUE because the delete-then-insert upsert pattern in SMTStoreSql
72
- // can race under concurrent access, causing duplicate key violations.
73
- await this.#db.schema
74
- .createIndex('index_stateIndexNodes_tenant_scope_nodeHash')
75
- .on(nodesTableName)
76
- .columns(['tenant', 'scope', 'nodeHash'])
77
- .execute();
78
- }
79
-
80
- // ─── Create stateIndexRoots table ─────────────────────────────────────
81
- const rootsTableName = 'stateIndexRoots';
82
- const rootsTableExists = await this.#dialect.hasTable(this.#db, rootsTableName);
83
- if (!rootsTableExists) {
84
- await this.#db.schema
85
- .createTable(rootsTableName)
86
- .ifNotExists()
87
- .addColumn('tenant', 'varchar(255)', (col) => col.notNull())
88
- .addColumn('scope', 'varchar(200)', (col) => col.notNull())
89
- .addColumn('rootHash', 'varchar(64)', (col) => col.notNull())
90
- .execute();
91
-
92
- await this.#db.schema
93
- .createIndex('index_stateIndexRoots_tenant_scope')
94
- .on(rootsTableName)
95
- .columns(['tenant', 'scope'])
96
- .execute();
97
- }
98
-
99
- // ─── Create stateIndexMeta table ──────────────────────────────────────
100
- const metaTableName = 'stateIndexMeta';
101
- const metaTableExists = await this.#dialect.hasTable(this.#db, metaTableName);
102
- if (!metaTableExists) {
103
- await this.#db.schema
104
- .createTable(metaTableName)
105
- .ifNotExists()
106
- .addColumn('tenant', 'varchar(255)', (col) => col.notNull())
107
- .addColumn('messageCid', 'varchar(60)', (col) => col.notNull())
108
- .addColumn('protocol', 'varchar(200)')
109
- .execute();
54
+ // Fail fast if migrations have not been run — tables must already exist.
55
+ await this.#assertTablesExist();
56
+ }
110
57
 
111
- await this.#db.schema
112
- .createIndex('index_stateIndexMeta_tenant_messageCid')
113
- .on(metaTableName)
114
- .columns(['tenant', 'messageCid'])
115
- .execute();
58
+ /**
59
+ * Verifies that the required tables exist by executing a zero-row SELECT.
60
+ * Throws a clear error directing the caller to run migrations first.
61
+ */
62
+ async #assertTablesExist(): Promise<void> {
63
+ const tables = ['stateIndexNodes', 'stateIndexRoots', 'stateIndexMeta'] as const;
64
+ for (const table of tables) {
65
+ try {
66
+ await sql`SELECT 1 FROM ${sql.table(table)} LIMIT 0`.execute(this.#db!);
67
+ } catch {
68
+ throw new Error(
69
+ `StateIndexSql: table '${table}' does not exist. Run DWN store migrations before opening stores.`
70
+ );
71
+ }
116
72
  }
117
73
  }
118
74
 
@@ -2,7 +2,7 @@ import type { DwnDatabaseType } from '../types.js';
2
2
  import type { Filter } from '@enbox/dwn-sdk-js';
3
3
  import type { ExpressionBuilder, OperandExpression, SelectQueryBuilder, SqlBool } from 'kysely';
4
4
 
5
- import { DynamicModule } from 'kysely';
5
+ import { DynamicModule, sql } from 'kysely';
6
6
  import { sanitizedValue, sanitizeFiltersAndSeparateTags } from './sanitize.js';
7
7
 
8
8
  /**
@@ -52,6 +52,16 @@ function processFilter<DB = DwnDatabaseType, TB extends keyof DB = keyof DB>(
52
52
  if (Array.isArray(value)) { // OneOfFilter
53
53
  andOperands.push(eb(column, 'in', value));
54
54
  } else if (typeof value === 'object') { // RangeFilter
55
+ // Detect prefix-style range filters created by `constructPrefixFilterAsRangeFilter`
56
+ // which uses `{ gte: prefix, lt: prefix + '\uffff' }`. The U+FFFF sentinel does not
57
+ // sort correctly under ICU/libc collation rules (e.g. PostgreSQL's en_US.UTF-8),
58
+ // so we convert these to a collation-safe SQL LIKE expression.
59
+ if (isPrefixRangeFilter(value)) {
60
+ const prefix = escapeLikePattern(value.gte as string);
61
+ andOperands.push(sql`${sql.ref(property)} LIKE ${prefix + '%'}`);
62
+ continue;
63
+ }
64
+
55
65
  if (value.gt) {
56
66
  andOperands.push(eb(column, '>', sanitizedValue(value.gt)));
57
67
  }
@@ -70,6 +80,30 @@ function processFilter<DB = DwnDatabaseType, TB extends keyof DB = keyof DB>(
70
80
  }
71
81
  }
72
82
 
83
+ /**
84
+ * Returns `true` if the given RangeFilter matches the pattern produced by
85
+ * `constructPrefixFilterAsRangeFilter`: `{ gte: <prefix>, lt: <prefix> + '\uffff' }`.
86
+ */
87
+ function isPrefixRangeFilter(value: Record<string, unknown>): boolean {
88
+ if (typeof value.gte !== 'string' || typeof value.lt !== 'string') {
89
+ return false;
90
+ }
91
+ // Only two keys: gte and lt (no gt, lte, or other properties)
92
+ const keys = Object.keys(value);
93
+ if (keys.length !== 2 || !keys.includes('gte') || !keys.includes('lt')) {
94
+ return false;
95
+ }
96
+ return value.lt === value.gte + '\uffff';
97
+ }
98
+
99
+ /**
100
+ * Escapes SQL LIKE meta-characters (`%`, `_`) in the given string so that
101
+ * they are matched literally.
102
+ */
103
+ function escapeLikePattern(input: string): string {
104
+ return input.replace(/%/g, '\\%').replace(/_/g, '\\_');
105
+ }
106
+
73
107
  /**
74
108
  * Processes each property in the tags filter as an AND operand and adds it to the `andOperands` array.
75
109
  * If a property has an array of values it will treat it as a OneOf (IN) within the overall AND query.