@quereus/store 4.4.1 → 4.6.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.
Files changed (104) hide show
  1. package/README.md +538 -491
  2. package/dist/src/common/encoding.d.ts +4 -3
  3. package/dist/src/common/encoding.d.ts.map +1 -1
  4. package/dist/src/common/encoding.js +17 -13
  5. package/dist/src/common/encoding.js.map +1 -1
  6. package/dist/src/common/events.d.ts +5 -1
  7. package/dist/src/common/events.d.ts.map +1 -1
  8. package/dist/src/common/events.js.map +1 -1
  9. package/dist/src/common/implicit-unique-index.d.ts +80 -0
  10. package/dist/src/common/implicit-unique-index.d.ts.map +1 -0
  11. package/dist/src/common/implicit-unique-index.js +148 -0
  12. package/dist/src/common/implicit-unique-index.js.map +1 -0
  13. package/dist/src/common/index.d.ts +7 -3
  14. package/dist/src/common/index.d.ts.map +1 -1
  15. package/dist/src/common/index.js +5 -2
  16. package/dist/src/common/index.js.map +1 -1
  17. package/dist/src/common/json-key.d.ts +2 -2
  18. package/dist/src/common/json-key.js +2 -2
  19. package/dist/src/common/key-builder.d.ts +40 -20
  20. package/dist/src/common/key-builder.d.ts.map +1 -1
  21. package/dist/src/common/key-builder.js +31 -23
  22. package/dist/src/common/key-builder.js.map +1 -1
  23. package/dist/src/common/kv-store.d.ts +10 -0
  24. package/dist/src/common/kv-store.d.ts.map +1 -1
  25. package/dist/src/common/pk-key-resolution.d.ts +275 -0
  26. package/dist/src/common/pk-key-resolution.d.ts.map +1 -0
  27. package/dist/src/common/pk-key-resolution.js +389 -0
  28. package/dist/src/common/pk-key-resolution.js.map +1 -0
  29. package/dist/src/common/store-module-access-plan.d.ts +32 -0
  30. package/dist/src/common/store-module-access-plan.d.ts.map +1 -0
  31. package/dist/src/common/store-module-access-plan.js +424 -0
  32. package/dist/src/common/store-module-access-plan.js.map +1 -0
  33. package/dist/src/common/store-module-alter-column.d.ts +23 -0
  34. package/dist/src/common/store-module-alter-column.d.ts.map +1 -0
  35. package/dist/src/common/store-module-alter-column.js +393 -0
  36. package/dist/src/common/store-module-alter-column.js.map +1 -0
  37. package/dist/src/common/store-module-alter.d.ts +55 -0
  38. package/dist/src/common/store-module-alter.d.ts.map +1 -0
  39. package/dist/src/common/store-module-alter.js +519 -0
  40. package/dist/src/common/store-module-alter.js.map +1 -0
  41. package/dist/src/common/store-module-base.d.ts +268 -0
  42. package/dist/src/common/store-module-base.d.ts.map +1 -0
  43. package/dist/src/common/store-module-base.js +362 -0
  44. package/dist/src/common/store-module-base.js.map +1 -0
  45. package/dist/src/common/store-module-catalog.d.ts +269 -0
  46. package/dist/src/common/store-module-catalog.d.ts.map +1 -0
  47. package/dist/src/common/store-module-catalog.js +462 -0
  48. package/dist/src/common/store-module-catalog.js.map +1 -0
  49. package/dist/src/common/store-module-index-build.d.ts +88 -0
  50. package/dist/src/common/store-module-index-build.d.ts.map +1 -0
  51. package/dist/src/common/store-module-index-build.js +280 -0
  52. package/dist/src/common/store-module-index-build.js.map +1 -0
  53. package/dist/src/common/store-module-index.d.ts +116 -0
  54. package/dist/src/common/store-module-index.d.ts.map +1 -0
  55. package/dist/src/common/store-module-index.js +373 -0
  56. package/dist/src/common/store-module-index.js.map +1 -0
  57. package/dist/src/common/store-module-rename.d.ts +49 -0
  58. package/dist/src/common/store-module-rename.d.ts.map +1 -0
  59. package/dist/src/common/store-module-rename.js +244 -0
  60. package/dist/src/common/store-module-rename.js.map +1 -0
  61. package/dist/src/common/store-module-schema-rewrite.d.ts +57 -0
  62. package/dist/src/common/store-module-schema-rewrite.d.ts.map +1 -0
  63. package/dist/src/common/store-module-schema-rewrite.js +140 -0
  64. package/dist/src/common/store-module-schema-rewrite.js.map +1 -0
  65. package/dist/src/common/store-module-schema-sync.d.ts +232 -0
  66. package/dist/src/common/store-module-schema-sync.d.ts.map +1 -0
  67. package/dist/src/common/store-module-schema-sync.js +529 -0
  68. package/dist/src/common/store-module-schema-sync.js.map +1 -0
  69. package/dist/src/common/store-module.d.ts +43 -909
  70. package/dist/src/common/store-module.d.ts.map +1 -1
  71. package/dist/src/common/store-module.js +184 -3194
  72. package/dist/src/common/store-module.js.map +1 -1
  73. package/dist/src/common/store-table-base.d.ts +523 -0
  74. package/dist/src/common/store-table-base.d.ts.map +1 -0
  75. package/dist/src/common/store-table-base.js +878 -0
  76. package/dist/src/common/store-table-base.js.map +1 -0
  77. package/dist/src/common/store-table-constraints.d.ts +237 -0
  78. package/dist/src/common/store-table-constraints.d.ts.map +1 -0
  79. package/dist/src/common/store-table-constraints.js +624 -0
  80. package/dist/src/common/store-table-constraints.js.map +1 -0
  81. package/dist/src/common/store-table-scan.d.ts +415 -0
  82. package/dist/src/common/store-table-scan.d.ts.map +1 -0
  83. package/dist/src/common/store-table-scan.js +821 -0
  84. package/dist/src/common/store-table-scan.js.map +1 -0
  85. package/dist/src/common/store-table.d.ts +71 -1044
  86. package/dist/src/common/store-table.d.ts.map +1 -1
  87. package/dist/src/common/store-table.js +130 -2131
  88. package/dist/src/common/store-table.js.map +1 -1
  89. package/dist/src/testing/index.d.ts +12 -0
  90. package/dist/src/testing/index.d.ts.map +1 -0
  91. package/dist/src/testing/index.js +12 -0
  92. package/dist/src/testing/index.js.map +1 -0
  93. package/dist/src/testing/kv-assert.d.ts +19 -0
  94. package/dist/src/testing/kv-assert.d.ts.map +1 -0
  95. package/dist/src/testing/kv-assert.js +25 -0
  96. package/dist/src/testing/kv-assert.js.map +1 -0
  97. package/dist/src/testing/kv-conformance.d.ts.map +1 -1
  98. package/dist/src/testing/kv-conformance.js +1 -15
  99. package/dist/src/testing/kv-conformance.js.map +1 -1
  100. package/dist/src/testing/kv-provider-conformance.d.ts +54 -0
  101. package/dist/src/testing/kv-provider-conformance.d.ts.map +1 -0
  102. package/dist/src/testing/kv-provider-conformance.js +183 -0
  103. package/dist/src/testing/kv-provider-conformance.js.map +1 -0
  104. package/package.json +9 -9
package/README.md CHANGED
@@ -1,491 +1,538 @@
1
- # @quereus/store
2
-
3
- > **Stability: Beta** — complete and tested, but the surface is still being shaped; a
4
- > breaking change may land in a minor release. Its on-disk key encoding is not frozen, so
5
- > a format change can make an existing database unreadable without a migration step. See
6
- > [Stability Tiers](../../docs/stability.md#tiers).
7
-
8
- Abstract key-value storage module for [Quereus](https://github.com/gotchoices/quereus). Provides platform-agnostic interfaces and a generic `StoreModule` virtual table implementation.
9
-
10
- ## Architecture
11
-
12
- This package provides the **abstract layer** that separates virtual table logic from platform-specific storage:
13
-
14
- ```
15
- @quereus/store (this package)
16
- ├── KVStore interface - Abstract key-value store
17
- ├── KVStoreProvider interface - Store factory/management
18
- ├── StoreModule - Generic VirtualTableModule
19
- ├── StoreTable - Generic virtual table implementation
20
- ├── StoreConnection - Generic transaction support
21
- └── Common utilities - Encoding, serialization, events
22
-
23
- @quereus/plugin-leveldb (Node.js) @quereus/plugin-indexeddb (Browser)
24
- ├── LevelDBStore ├── IndexedDBStore
25
- ├── LevelDBProvider ├── IndexedDBProvider
26
- └── Plugin registration ├── IndexedDBManager
27
- └── CrossTabSync
28
- ```
29
-
30
- This architecture enables:
31
- - **Platform portability** - Same SQL tables work across Node.js, browsers, and mobile
32
- - **Custom storage backends** - Implement `KVStore` for IndexedDB, LevelDB, LMDB, or other "NoSQL" stores
33
- - **Dependency injection** - Use `KVStoreProvider` for store management
34
-
35
- ## Storage Architecture
36
-
37
- The store module uses separate logical stores for different data types:
38
-
39
- **Store Naming Convention:**
40
- - `{schema}.{table}` - Data store (row data)
41
- - `{schema}.{table}_idx_{indexName}` - Index stores (one per secondary index)
42
- - `{prefix}.__stats__` - Unified stats store (row counts for all tables)
43
- - `__catalog__` - Catalog store (DDL metadata)
44
-
45
- **Key Formats:**
46
- - **Data keys**: Encoded primary key (no prefix)
47
- - **Index keys**: Encoded index columns + encoded PK
48
- - **Index values**: The row's encoded **data key**. A secondary-index scan resolves each
49
- matched entry back to its base row with one data-store read at this key, rather than
50
- decoding the index key's PK suffix (that suffix is encoded lossily for a NOCASE/RTRIM
51
- PK column, so it is not recoverable to SQL values). Entries are not covering — the row
52
- itself always lives in the data store.
53
- - **Catalog keys**:
54
- - Tables: `{schema}.{table}` as a string (the `CREATE TABLE` bundle, with its index DDL and any exposed-implicit-index tag DDL)
55
- - Views: `\x00view\x00{schema}.{view}` (reserved-prefix; `generateViewDDL`)
56
- - Materialized views: `\x00mview\x00{schema}.{mv}` (reserved-prefix; `generateMaterializedViewDDL`)
57
-
58
- This design eliminates redundant prefixes and groups related stores together by table name. The leading-`0x00` view/MV prefixes never collide with an unprefixed table key, so a view/MV may safely share a name with a table; a full catalog scan returns all three kinds intermixed and rehydrate classifies each by its key prefix.
59
-
60
- **Catalog DDL is re-persisted on catalog-only mutations.** `ALTER … SET TAGS` (and the programmatic `setTableTags` / `setColumnTags` / `setConstraintTags` / `setViewTags` / `setMaterializedViewTags`), plus `CREATE`/`DROP VIEW` and `CREATE`/`DROP MATERIALIZED VIEW`, never reach `module.alterTable`/`module.destroy`. The module subscribes to the engine's schema-change events (`table_modified`, the `view_*` events, and the `materialized_view_*` events) and writes the matching `__catalog__` entry when its `generate*DDL` output changes — table / column / constraint / **index** / **view** / **materialized-view** tags, and view/MV lifecycle, all survive close → reopen. A table's bundle is its `CREATE TABLE` DDL, one `CREATE [UNIQUE] INDEX` line per secondary index, and one trailing `alter index … set tags (…)` line per *exposed implicit index* carrying user tags (an exposed implicit index is never materialized in the store's *engine-facing* schema — only in its internal enforcement schema, see the implicit-index note below — so its `UniqueConstraintSchema.exposedIndexTags` has no `CREATE INDEX` line to ride; the alter line re-applies silently on import). These async writes are serialized and drained by `closeAll()` (or the `whenCatalogPersisted()` barrier) before the provider closes. On reopen, `rehydrateCatalog` classifies entries by key prefix and imports them in phases — tables → views → materialized views, all through the engine's `importCatalog` (MVs re-materialize silently via the shared create core, dependency-ordered for MV-over-MV by fixpoint retry). See [`docs/schema.md`](../../docs/schema.md#view-and-materialized-view-persistence) for the full design.
61
-
62
- **How a UNIQUE constraint is enforced.** For each row written, the store looks for a conflicting row through the cheapest sound route available:
63
-
64
- 1. **A linked row-time covering materialized view** — its backing table answers the uniqueness question.
65
- 2. **A physical secondary index realizing the constraint** — one bounded seek into the index store. Available for **every** non-derived `UNIQUE` (see the implicit-index note below), for a constraint that came from a `CREATE UNIQUE INDEX` (it names its own index), and for any *full* (non-partial) index whose columns match the constraint's. The index need not itself be UNIQUE.
66
- 3. **A full scan of the data store** — always correct, and O(rows) per row written.
67
-
68
- Route 2 turns a bulk insert from O(n²) into roughly O(n log n). It is skipped for a constraint the index cannot answer soundly:
69
-
70
- - A **partial** index cannot serve a constraint it does not derive from: it physically omits its out-of-scope rows, so a seek would miss a conflict among them.
71
- - Index-column bytes are encoded under the **table key collation** `K` (the `collation` module option, default `NOCASE`), not under the constraint's enforcement collation `C`. A seek returns exactly the rows `K`-equal to the new row, so it is a sound *superset* of the true conflict set only when `K` is coarser-than-or-equal-to `C` (a column that can never hold text; `C == K`; or `K = NOCASE` over `C = BINARY`). When `K` is finer — `K = BINARY` over `C = NOCASE`, or `K = NOCASE` over `C = RTRIM` — a seek would under-fetch and silently accept a real duplicate, so the constraint falls back to the full scan. "Can never hold text" is judged by physical representation, not by declared type name: an `ANY` or `JSON` column stores a string as a string, so neither is exempt.
72
-
73
- Whichever route runs, the conflicting row is re-validated identically: the row being written is excluded by primary key, each constrained column is compared under its enforcement collation (a `CREATE UNIQUE INDEX … (col COLLATE x)` enforces `x`, else the column's declared collation), and a partial constraint's predicate must hold on the candidate.
74
-
75
- Because route 2 trusts the index store to hold an entry for every live row, `CREATE INDEX` populates the new index from the table's **effective** rows — committed rows merged with the open transaction's pending writes. A row inserted earlier in that transaction is therefore indexed, and participates in `CREATE UNIQUE INDEX`'s duplicate check, rather than being invisible to every later seek. Index entries are written outside the transaction coordinator, so a later `ROLLBACK` leaves entries for rows that no longer exist; every reader resolves an index entry to its live row and drops it when the row is gone or no longer matches, so a stale entry can never manufacture a result.
76
-
77
- **Implicit per-constraint index (`_uc_*`).** Every non-derived `UNIQUE` — declared inline at `CREATE TABLE` or added by `ALTER TABLE … ADD CONSTRAINT` — is backed by a hidden secondary index named `<constraint name>` or, when unnamed, `_uc_<columns>` (the same convention the memory backend and the engine's `implicitIndexName` use). This is what makes route 2 reach a plain `UNIQUE`, so a bulk load no longer degrades to O(n²). The index is:
78
-
79
- - **Kept out of the engine.** It lives only in the StoreTable's *enforcement* schema, never in the engine-registered schema, so the read-query planner does not see it (a plain `UNIQUE` gets no read-side plan from it — matching the memory backend) and it is never written to the catalog as a `CREATE INDEX`.
80
- - **Derived on open.** Reconstructing a StoreTable re-materializes the schema entry from `uniqueConstraints`; the physical index store persists on disk under its deterministic name and is reopened lazily. (A store written *before* this feature has no `_uc_*` store on disk — backwards compatibility is waived project-wide; reopening such a database would need the index rebuilt.)
81
- - **Reconciled across ALTER.** `ADD CONSTRAINT` builds the physical store from the existing rows (after the existing-row duplicate check passes); `DROP CONSTRAINT` tears it down (so a later re-`ADD` cannot reopen stale entries); `RENAME CONSTRAINT`, and a column rename that changes an unnamed constraint's implicit name, move the store; a PK / collation / data-type `ALTER` re-encodes it via the same rebuild that re-encodes explicit indexes.
82
- - **Relocated / reclaimed with the table.** `DROP TABLE` deletes the `_uc_*` store alongside the data + explicit-index stores; `RENAME TABLE` relocates it under the new name. Both resolve the physical store list from the *enforcement* schema (not the engine-facing `.indexes`, which omits `_uc_*`), so the implicit store is never stranded on drop nor left behind on rename (which would leave the renamed table seeking an empty store and accepting a duplicate).
83
- - **Always materialized, even alongside an explicit index.** When a user also declares a matching `CREATE INDEX`, both are maintained (their key bytes are byte-identical, so this is redundant, never wrong). Reusing the explicit index instead is a deferred optimization — see `tickets/backlog/debt-store-implicit-unique-index-reuse`.
84
-
85
- ## Installation
86
-
87
- ```bash
88
- npm install @quereus/store
89
- ```
90
-
91
- For platform-specific implementations:
92
- ```bash
93
- # Node.js
94
- npm install @quereus/plugin-leveldb
95
-
96
- # Browser
97
- npm install @quereus/plugin-indexeddb
98
- ```
99
-
100
- ## Usage
101
-
102
- ### With a Provider
103
-
104
- ```typescript
105
- import { Database } from '@quereus/quereus';
106
- import { StoreModule } from '@quereus/store';
107
- import { createLevelDBProvider } from '@quereus/plugin-leveldb';
108
- // OR: import { createIndexedDBProvider } from '@quereus/plugin-indexeddb';
109
-
110
- const db = new Database();
111
-
112
- // Create provider for your platform
113
- const provider = createLevelDBProvider({ basePath: './data' });
114
-
115
- // Create the generic store module with your provider
116
- const storeModule = new StoreModule(provider);
117
- db.registerModule('store', storeModule);
118
-
119
- // Use it in SQL
120
- await db.exec(`
121
- create table users (id integer primary key, name text)
122
- using store
123
- `);
124
- ```
125
-
126
- ### Custom Storage Backend
127
-
128
- Implement `KVStore` and `KVStoreProvider` to create custom storage backends:
129
-
130
- ```typescript
131
- import type { KVStore, KVStoreProvider } from '@quereus/store';
132
-
133
- class MyCustomStore implements KVStore {
134
- async get(key: Uint8Array) { /* ... */ }
135
- async put(key: Uint8Array, value: Uint8Array) { /* ... */ }
136
- async delete(key: Uint8Array) { /* ... */ }
137
- async has(key: Uint8Array) { /* ... */ }
138
- iterate(options?: IterateOptions) { /* ... */ }
139
- batch() { /* ... */ }
140
- async close() { /* ... */ }
141
- async approximateCount(options?: IterateOptions) { /* ... */ }
142
- }
143
-
144
- class MyCustomProvider implements KVStoreProvider {
145
- async getStore(schemaName: string, tableName: string) {
146
- return new MyCustomStore(/* ... */);
147
- }
148
- async getIndexStore(schemaName: string, tableName: string, indexName: string) {
149
- return new MyCustomStore(/* ... */);
150
- }
151
- async getStatsStore(schemaName: string, tableName: string) {
152
- return new MyCustomStore(/* ... */);
153
- }
154
- async getCatalogStore() { /* ... */ }
155
- async closeStore(schemaName: string, tableName: string) { /* ... */ }
156
- async closeIndexStore(schemaName: string, tableName: string, indexName: string) { /* ... */ }
157
- async closeAll() { /* ... */ }
158
- }
159
-
160
- // Use it with StoreModule
161
- const provider = new MyCustomProvider();
162
- const module = new StoreModule(provider);
163
- db.registerModule('store', module);
164
- ```
165
-
166
- **Validate a new backend against the shared conformance suite.** `@quereus/store/testing`
167
- exports `runKVStoreConformance(name, makeBackend)` — one parameterized battery of
168
- behavioral tests written against the `KVStore` contract (point ops, ordering, range
169
- iteration, streaming across page boundaries, batch semantics, optional persistence, and
170
- cross-backend encoded-key ordering). Wire a tiny lifecycle adapter and run it under Mocha
171
- so any drift from the contract fails a test:
172
-
173
- ```typescript
174
- import { runKVStoreConformance } from '@quereus/store/testing';
175
-
176
- runKVStoreConformance('MyCustomStore', () => ({
177
- open: async () => new MyCustomStore(/* ... */),
178
- // Omit `reopen` for a non-persistent backend; supply it (reopen the SAME keyspace
179
- // without wiping) to also exercise the persistence tier.
180
- teardown: async () => { /* close handles, remove backing storage */ },
181
- }));
182
- ```
183
-
184
- See `test/kv-conformance.spec.ts` (in-memory), and the LevelDB / IndexedDB plugins'
185
- `test/conformance.spec.ts` for worked adapters.
186
-
187
- ## KVStore Interface
188
-
189
- The `KVStore` interface is the foundation for all storage backends:
190
-
191
- ```typescript
192
- interface KVStore {
193
- get(key: Uint8Array): Promise<Uint8Array | undefined>;
194
- put(key: Uint8Array, value: Uint8Array): Promise<void>;
195
- delete(key: Uint8Array): Promise<void>;
196
- has(key: Uint8Array): Promise<boolean>;
197
- iterate(options?: IterateOptions): AsyncIterable<KVEntry>;
198
- batch(): WriteBatch;
199
- close(): Promise<void>;
200
- approximateCount(options?: IterateOptions): Promise<number>;
201
- }
202
-
203
- interface KVStoreProvider {
204
- // Get data store for a table
205
- getStore(schemaName: string, tableName: string): Promise<KVStore>;
206
-
207
- // Get index store for a secondary index
208
- getIndexStore(schemaName: string, tableName: string, indexName: string): Promise<KVStore>;
209
-
210
- // Get stats store for table statistics
211
- getStatsStore(schemaName: string, tableName: string): Promise<KVStore>;
212
-
213
- // Get catalog store for DDL metadata
214
- getCatalogStore(): Promise<KVStore>;
215
-
216
- // Close specific stores
217
- closeStore(schemaName: string, tableName: string): Promise<void>;
218
- closeIndexStore(schemaName: string, tableName: string, indexName: string): Promise<void>;
219
- closeAll(): Promise<void>;
220
-
221
- // Optional: Delete stores. `indexNames` is the table's exact secondary-index
222
- // names (from the schema) — build index store names from it via
223
- // buildIndexStoreName instead of prefix-scanning `{table}_idx_`, which would
224
- // also match a sibling table literally named `{table}_idx_<x>`.
225
- deleteIndexStore?(schemaName: string, tableName: string, indexName: string): Promise<void>;
226
- deleteTableStores?(schemaName: string, tableName: string, indexNames: readonly string[]): Promise<void>;
227
-
228
- // Optional: Relocate a table's data + index stores for ALTER TABLE ... RENAME TO
229
- // (`indexNames` carries the same authoritative, exact index list).
230
- renameTableStores?(schemaName: string, oldName: string, newName: string, indexNames: readonly string[]): Promise<void>;
231
- }
232
- ```
233
-
234
- ## Module Capabilities
235
-
236
- The `StoreModule` reports its capabilities via `getCapabilities()`:
237
-
238
- ```typescript
239
- const storeModule = new StoreModule(provider);
240
- const caps = storeModule.getCapabilities();
241
-
242
- // {
243
- // isolation: false, // Store module does NOT provide transaction isolation
244
- // savepoints: true, // Coordinator-buffered ops support savepoints within a transaction
245
- // persistent: true, // Data persists across restarts
246
- // secondaryIndexes: true,// Supports secondary indexes
247
- // rangeScans: true // Supports range scans
248
- // }
249
- ```
250
-
251
- **Important:** The base `StoreModule` does not provide transaction isolation:
252
- - No snapshot isolation: between connections, reads see only committed data, and concurrent readers may observe partial writes
253
- - Within a transaction, reads through the table's shared coordinator DO see that transaction's own pending writes (read-your-own-writes). This extends to DML's own internal reads: the insert PK-conflict probe, the update/delete old-image reads, and the update PK-change conflict probe all read through the pending merge, so an INSERT/UPDATE/DELETE against a row written earlier in the same transaction sees that pending row — it raises a UNIQUE conflict (or evicts under `OR REPLACE`), cleans up secondary-index entries, tracks the correct row-count delta, and emits events carrying the pending `oldRow`
254
- - Row-validating DDL reads the same effective stream: `create index` / `create unique index` populate from it (see above), and `alter table … add constraint … unique` plus the `set collate` re-validation of a covering non-PK UNIQUE scan it too — so a duplicate inserted earlier in the still-open transaction is rejected rather than surviving to commit
255
- - Savepoints (create / release / rollback-to) work within a transaction via the coordinator's buffered op log
256
- - Caveat: a DDL-commit operation (`replaceContents` / `renameTable`, e.g. `refresh materialized view` or `alter table … rename`) commits the coordinator mid-transaction, clearing the savepoint stack. A later `rollback to` / `release` targeting a now-vanished savepoint degrades to a no-op (warn-and-return) rather than throwing; the committed DDL and everything before it stays committed
257
-
258
- ## Atomic multi-store commit (module-wide, cross-table)
259
-
260
- A single `TransactionCoordinator` is shared by **every table of one storage
261
- module** — it is the unit of cross-table atomicity. Every buffered op is
262
- addressed by its explicit target `KVStore` handle (data ops, secondary-index
263
- ops, and backing-host writes alike), so a transaction touching tables A and B
264
- accumulates all of their stores' ops in one coordinator. Because the engine
265
- commits virtual-table connections **sequentially** and the coordinator's
266
- `commit()`/`rollback()` are **idempotent**, the first connection to commit
267
- flushes **every** touched store of **every** table the transaction wrote; the
268
- remaining connections no-op.
269
-
270
- `TransactionCoordinator.commit()` thus writes each table's data store and each of
271
- its secondary-index stores. By default it writes **one `KVStore.batch()` per
272
- store, sequentially** — a crash between those batches can leave tables/indexes
273
- divergent on disk, with no automatic healing (no worse than the prior per-table
274
- commits, which were already non-atomic across tables).
275
-
276
- A provider whose stores share a single durable commit domain can close that
277
- window by implementing the optional `KVStoreProvider.beginAtomicBatch()`:
278
-
279
- ```typescript
280
- interface AtomicBatch {
281
- put(store: KVStore, key: Uint8Array, value: Uint8Array): void;
282
- delete(store: KVStore, key: Uint8Array): void;
283
- write(): Promise<void>; // one durable, all-or-nothing physical commit
284
- clear(): void;
285
- }
286
-
287
- interface KVStoreProvider {
288
- // ...
289
- // Open a batch spanning this provider's stores, or undefined when the provider
290
- // has no shared commit domain (the coordinator then falls back to per-store batch()).
291
- beginAtomicBatch?(): AtomicBatch | undefined;
292
- }
293
- ```
294
-
295
- The batch addresses stores by **`KVStore` handle**, so it composes with the
296
- coordinator's existing per-store bucketing without a name lookup. When present,
297
- `commit()` queues every pending op — every store of **every table** in the
298
- transaction — into one `AtomicBatch` and issues a single `write()`; all of those
299
- tables commit or roll back together. When absent — or when the factory returns
300
- `undefined` — behavior is byte-identical to the per-store loop, so providers
301
- without a shared domain are unaffected.
302
-
303
- The capability surface spans **multiple stores of one provider** (every store of
304
- every table the module owns), giving full module-wide cross-table atomicity with
305
- no interface change. The
306
- [`@quereus/plugin-indexeddb`](../quereus-plugin-indexeddb) provider implements it
307
- over its single IndexedDB database (multiple object stores, one
308
- `db.transaction(...,'readwrite')`), invalidating each touched store's read cache
309
- after a successful write so read-your-own-writes survives the cache.
310
-
311
- ## Materialized-View Backing Host
312
-
313
- The store module implements the engine's backing-host capability
314
- (`StoreBackingHost`), so `create materialized view mv using store as <body>`
315
- places the MV's backing table in persistent storage. Maintenance writes ride
316
- the module's shared `TransactionCoordinator`'s pending state (committing/rolling
317
- back in lockstep with the source write — and, since the coordinator is
318
- module-wide, in the same all-or-nothing batch as a write to a same-module
319
- source), mid-transaction reads of the MV see pending
320
- maintenance through the read-your-own-writes merge, and the backing's text
321
- primary-key columns are keyed under the store's `collation` arg (default
322
- `NOCASE` — pass `using store(collation = 'BINARY')` for byte-exact keys). The
323
- isolation wrapper forwards the capability automatically. See
324
- [`docs/mv-backing-host.md` § The store host](../../docs/mv-backing-host.md#the-store-host-using-store).
325
-
326
- ## External Row-Write Entry Point
327
-
328
- `StoreTable.applyExternalRowChanges(ops)` applies trusted, externally-originated
329
- row writes (e.g. inbound replication) directly to a **source** table's committed
330
- storage — table-owned data-key put/delete, **secondary-index maintenance**, and
331
- stats tracking — and returns the effective `BackingRowChange[]` (the shape
332
- `Database.ingestExternalRowChanges` consumes). It is the index-maintaining
333
- sibling of the backing host (whose MV backing tables carry no indexes): a caller
334
- writing the data `KVStore` directly would silently skip index and stats upkeep.
335
-
336
- Resolve the table with `StoreModule.getTableForExternalWrite(db, schema, table)`
337
- (same ownership/wrapper resolution as `getBackingHost`), read a row's current
338
- image with `StoreTable.readRowByPk(pk)`, then apply one `ExternalRowOp` per row:
339
-
340
- ```typescript
341
- const table = storeModule.getTableForExternalWrite(db, 'main', 'users');
342
- if (table) {
343
- const changes = await table.applyExternalRowChanges([
344
- { op: 'upsert', row: [1, 'alice'] }, // full row, schema column order
345
- { op: 'delete', pk: [2] }, // PK values, PK-definition order
346
- ]);
347
- }
348
- ```
349
-
350
- Deliberately emits **no** module data events (the caller owns emission and the
351
- `remote` flag), opens **no** coordinator transaction (writes commit at once,
352
- last-writer-wins against any pending local transaction), and runs **no**
353
- constraint validation (the origin is trusted). No-ops are suppressed: a delete
354
- of an absent key and a value-identical upsert (byte-faithful) write nothing and
355
- report nothing.
356
-
357
- ## Transaction Isolation
358
-
359
- To add full ACID transaction semantics with snapshot isolation, wrap the store module with the `IsolationModule`:
360
-
361
- ```typescript
362
- import { Database, MemoryTableModule } from '@quereus/quereus';
363
- import { IsolationModule } from '@quereus/isolation';
364
- import { StoreModule, createIsolatedStoreModule } from '@quereus/store';
365
- import { createLevelDBProvider } from '@quereus/plugin-leveldb';
366
-
367
- const db = new Database();
368
- const provider = createLevelDBProvider({ basePath: './data' });
369
-
370
- // Option 1: Use the convenience function
371
- const isolatedModule = createIsolatedStoreModule({ provider });
372
- db.registerModule('store', isolatedModule);
373
-
374
- // Option 2: Manual wrapping for more control
375
- const storeModule = new StoreModule(provider);
376
- const isolatedModule = new IsolationModule({
377
- underlying: storeModule,
378
- overlay: new MemoryTableModule(),
379
- });
380
- db.registerModule('store', isolatedModule);
381
-
382
- // Now transactions have full isolation
383
- await db.exec('BEGIN');
384
- await db.exec(`INSERT INTO users VALUES (1, 'Alice')`);
385
-
386
- // Read-your-own-writes: sees uncommitted insert
387
- const user = await db.get('SELECT * FROM users WHERE id = 1');
388
- console.log(user.name); // 'Alice'
389
-
390
- await db.exec('COMMIT'); // Or ROLLBACK to discard
391
- ```
392
-
393
- The isolation layer provides:
394
- - **Read-your-own-writes** within transactions
395
- - **Snapshot isolation** for consistent reads
396
- - **Savepoint support** via the overlay module
397
-
398
- ### Checking for Isolation Support
399
-
400
- ```typescript
401
- import { hasIsolation } from '@quereus/store';
402
-
403
- const storeModule = new StoreModule(provider);
404
- console.log(hasIsolation(storeModule)); // false
405
-
406
- const isolatedModule = createIsolatedStoreModule({ provider });
407
- console.log(hasIsolation(isolatedModule)); // true
408
- ```
409
-
410
- ## API
411
-
412
- ### Core Exports
413
-
414
- | Export | Description |
415
- |--------|-------------|
416
- | `KVStore` | Key-value store interface (type) |
417
- | `KVStoreProvider` | Store factory interface (type) |
418
- | `WriteBatch` | Batch write interface (type) |
419
- | `AtomicBatch` | Cross-store all-or-nothing batch from `KVStoreProvider.beginAtomicBatch` (type) |
420
- | `IterateOptions` | Iteration options (type) |
421
- | `StoreModule` | Generic VirtualTableModule |
422
- | `StoreTable` | Virtual table implementation (incl. `applyExternalRowChanges` / `readRowByPk` for externally-applied source writes) |
423
- | `ExternalRowOp` | One externally-applied row op (`upsert`/`delete`) for `StoreTable.applyExternalRowChanges` (type) |
424
- | `resolvePkKeyCollations` | Per-PK-column key collations (pass to `buildDataKey`/`buildIndexKey` to match `StoreTable`'s key bytes) |
425
- | `StoreConnection` | Transaction connection |
426
- | `TransactionCoordinator` | Transaction management |
427
- | `StoreEventEmitter` | Event system for data/schema changes |
428
-
429
- ### Isolation Layer Utilities
430
-
431
- | Export | Description |
432
- |--------|-------------|
433
- | `createIsolatedStoreModule` | Create store module with isolation layer |
434
- | `hasIsolation` | Check if a module has isolation capability |
435
- | `IsolatedStoreModuleConfig` | Configuration for isolated store module |
436
-
437
- ### Caching
438
-
439
- | Export | Description |
440
- |--------|-------------|
441
- | `CachedKVStore` | Read-through LRU cache wrapper for any `KVStore` |
442
- | `CacheOptions` | Configuration for cache (maxEntries, maxBytes, enabled) |
443
-
444
- ### Encoding Utilities
445
-
446
- | Export | Description |
447
- |--------|-------------|
448
- | `encodeValue` | Encode a SQL value to sortable bytes |
449
- | `decodeValue` | Decode bytes back to SQL value |
450
- | `encodeCompositeKey` | Encode multiple values as composite key |
451
- | `decodeCompositeKey` | Decode composite key to values |
452
- | `BUILTIN_KEY_NORMALIZER_RESOLVER` | Built-ins-only key-normalizer resolver (`EncodeOptions.normalizers` default) |
453
-
454
- ### Serialization Utilities
455
-
456
- | Export | Description |
457
- |--------|-------------|
458
- | `serializeRow` | Serialize a row to bytes |
459
- | `deserializeRow` | Deserialize bytes to row |
460
- | `serializeValue` | Serialize a single value |
461
- | `deserializeValue` | Deserialize a single value |
462
-
463
- ### Key Building
464
-
465
- | Export | Description |
466
- |--------|-------------|
467
- | `buildDataStoreName` | Build store name for table data |
468
- | `buildIndexStoreName` | Build store name for an index |
469
- | `buildStatsStoreName` | Build store name for table stats |
470
- | `buildDataKey` | Build key for row data (encoded PK) |
471
- | `buildIndexKey` | Build key for index entry |
472
- | `buildCatalogKey` | Build key for a table's catalog entry (`{schema}.{table}`) |
473
- | `buildViewCatalogKey` | Build key for a view's catalog entry (reserved `\x00view\x00` prefix) |
474
- | `buildMaterializedViewCatalogKey` | Build key for an MV's catalog entry (reserved `\x00mview\x00` prefix) |
475
- | `classifyCatalogKey` | Classify a loaded catalog key as `'table'` / `'view'` / `'materializedView'` |
476
- | `buildFullScanBounds` | Build bounds for full table scan |
477
- | `buildIndexPrefixBounds` | Build bounds for index prefix scan |
478
- | `buildPkPrefixBounds` | Build bounds for a data-store PK prefix range (per-column DESC + key collations) |
479
- | `buildCatalogScanBounds` | Build bounds for catalog scan |
480
- | `CATALOG_STORE_NAME` | Reserved catalog store name constant |
481
- | `STORE_SUFFIX` | Store name suffixes (INDEX, STATS) |
482
-
483
- ## Related Packages
484
-
485
- - [`@quereus/plugin-leveldb`](../quereus-plugin-leveldb/) - LevelDB implementation for Node.js
486
- - [`@quereus/plugin-indexeddb`](../quereus-plugin-indexeddb/) - IndexedDB implementation for browsers
487
- - [`@quereus/sync`](../quereus-sync/) - CRDT sync layer
488
-
489
- ## License
490
-
491
- MIT
1
+ # @quereus/store
2
+
3
+ > **Stability: Beta** — complete and tested, but the surface is still being shaped; a
4
+ > breaking change may land in a minor release. Its on-disk key encoding is not frozen, so
5
+ > a format change can make an existing database unreadable without a migration step. See
6
+ > [Stability Tiers](../../docs/stability.md#tiers).
7
+
8
+ Abstract key-value storage module for [Quereus](https://github.com/gotchoices/quereus). Provides platform-agnostic interfaces and a generic `StoreModule` virtual table implementation.
9
+
10
+ ## Architecture
11
+
12
+ This package provides the **abstract layer** that separates virtual table logic from platform-specific storage:
13
+
14
+ ```
15
+ @quereus/store (this package)
16
+ ├── KVStore interface - Abstract key-value store
17
+ ├── KVStoreProvider interface - Store factory/management
18
+ ├── StoreModule - Generic VirtualTableModule
19
+ ├── StoreTable - Generic virtual table implementation
20
+ ├── StoreConnection - Generic transaction support
21
+ └── Common utilities - Encoding, serialization, events
22
+
23
+ @quereus/plugin-leveldb (Node.js) @quereus/plugin-indexeddb (Browser)
24
+ ├── LevelDBStore ├── IndexedDBStore
25
+ ├── LevelDBProvider ├── IndexedDBProvider
26
+ └── Plugin registration ├── IndexedDBManager
27
+ └── CrossTabSync
28
+ ```
29
+
30
+ This architecture enables:
31
+ - **Platform portability** - Same SQL tables work across Node.js, browsers, and mobile
32
+ - **Custom storage backends** - Implement `KVStore` for IndexedDB, LevelDB, LMDB, or other "NoSQL" stores
33
+ - **Dependency injection** - Use `KVStoreProvider` for store management
34
+
35
+ ## Storage Architecture
36
+
37
+ The store module uses separate logical stores for different data types:
38
+
39
+ **Store Naming Convention:**
40
+ - `{schema}.{table}` - Data store (row data)
41
+ - `{schema}.{table}_idx_{indexName}` - Index stores (one per secondary index)
42
+ - `{prefix}.__stats__` - Unified stats store (row counts for all tables)
43
+ - `__catalog__` - Catalog store (DDL metadata)
44
+
45
+ **Key Formats:**
46
+ - **Data keys**: Encoded primary key (no prefix)
47
+ - **Index keys**: Encoded index columns + encoded PK
48
+ - **Index values**: The row's encoded **data key**. A secondary-index scan resolves each
49
+ matched entry back to its base row with one data-store read at this key, rather than
50
+ decoding the index key's PK suffix (that suffix is encoded lossily for a NOCASE/RTRIM
51
+ PK column, so it is not recoverable to SQL values). Entries are not covering — the row
52
+ itself always lives in the data store.
53
+ - **Catalog keys**:
54
+ - Tables: `{schema}.{table}` as a string (the `CREATE TABLE` bundle, with its index DDL and any exposed-implicit-index tag DDL)
55
+ - Views: `\x00view\x00{schema}.{view}` (reserved-prefix; `generateViewDDL`)
56
+ - Materialized views: `\x00mview\x00{schema}.{mv}` (reserved-prefix; `generateMaterializedViewDDL`)
57
+
58
+ This design eliminates redundant prefixes and groups related stores together by table name. The leading-`0x00` view/MV prefixes never collide with an unprefixed table key, so a view/MV may safely share a name with a table; a full catalog scan returns all three kinds intermixed and rehydrate classifies each by its key prefix.
59
+
60
+ **`IN`-list index seeks ("multi-seek").** An `IN`-list on an indexed column
61
+ (`where v in (1, 2, 3)`, including parameter-bound lists) is served from the index as one
62
+ deduplicated, key-ordered point seek per distinct list value, instead of a full scan with
63
+ a residual filter. `NULL` list values match nothing and are skipped, duplicate values
64
+ yield their rows once, and a composite index serves the cross-product of per-column lists
65
+ (`a in (1,2) and b in (10,20)` is four seeks). Very large lists (over 1000 seek keys) and
66
+ lists on semantically-ordered column types (TIMESPAN, JSON) fall back to the scan path —
67
+ still correct, just not accelerated. `IN` on the primary key currently scans (see backlog
68
+ `feat-store-pk-in-list-multiseek`). Index choice among several usable indexes is
69
+ first-match, not cheapest — see backlog `bug-store-index-choice-ignores-cost`.
70
+
71
+ The same multi-seek path also serves an `IN` whose values only exist once the query runs
72
+ — `where v in (select … )`, which the engine may materialize into a set and hand down as
73
+ a seek (its `KeySetSemiJoin`). **Nothing in this module distinguishes the two.** The
74
+ engine stamps a `FilterInfo` byte-identical in shape to a literal list's, so every gate
75
+ above applies unchanged: the 1000-key cap, the semantic-ordering decline, the key-collation
76
+ guard, the partial-index exclusion, and the primary-key arm's refusal of any `IN`. At plan
77
+ time such a set describes itself only by a ceiling (`PredicateConstraint.runtimeSet.maxCount`),
78
+ which is what the cost and cap arithmetic judges. The engine also re-checks every row the
79
+ seek returns, so an over-fetching window costs only time.
80
+
81
+ **Catalog DDL is re-persisted on catalog-only mutations.** `ALTER … SET TAGS` (and the programmatic `setTableTags` / `setColumnTags` / `setConstraintTags` / `setViewTags` / `setMaterializedViewTags`), plus `CREATE`/`DROP VIEW` and `CREATE`/`DROP MATERIALIZED VIEW`, never reach `module.alterTable`/`module.destroy`. The module subscribes to the engine's schema-change events (`table_modified`, the `view_*` events, and the `materialized_view_*` events) and writes the matching `__catalog__` entry when its `generate*DDL` output changes — table / column / constraint / **index** / **view** / **materialized-view** tags, and view/MV lifecycle, all survive close → reopen. A table's bundle is its `CREATE TABLE` DDL, one `CREATE [UNIQUE] INDEX` line per secondary index, and one trailing `alter index … set tags (…)` line per *exposed implicit index* carrying user tags (an exposed implicit index is never materialized in the store's *engine-facing* schema — only in its internal enforcement schema, see the implicit-index note below — so its `UniqueConstraintSchema.exposedIndexTags` has no `CREATE INDEX` line to ride; the alter line re-applies silently on import). These async writes are serialized and drained by `closeAll()` (or the `whenCatalogPersisted()` barrier) before the provider closes. On reopen, `rehydrateCatalog` classifies entries by key prefix and imports them in phases — tables → views → materialized views, all through the engine's `importCatalog` (MVs re-materialize silently via the shared create core, dependency-ordered for MV-over-MV by fixpoint retry). See [`docs/view-persistence.md`](../../docs/view-persistence.md#view-and-materialized-view-persistence) for the full design.
82
+
83
+ **How a UNIQUE constraint is enforced.** For each row written, the store looks for a conflicting row through the cheapest sound route available:
84
+
85
+ 1. **A linked row-time covering materialized view** — its backing table answers the uniqueness question.
86
+ 2. **A physical secondary index realizing the constraint** — one bounded seek into the index store. Available for **every** non-derived `UNIQUE` (see the implicit-index note below), for a constraint that came from a `CREATE UNIQUE INDEX` (it names its own index), and for any *full* (non-partial) index whose columns match the constraint's. The index need not itself be UNIQUE.
87
+ 3. **A full scan of the data store** — always correct, and O(rows) per row written.
88
+
89
+ Route 2 turns a bulk insert from O(n²) into roughly O(n log n). It is skipped for a constraint the index cannot answer soundly:
90
+
91
+ - A **partial** index cannot serve a constraint it does not derive from: it physically omits its out-of-scope rows, so a seek would miss a conflict among them.
92
+ - Index-column bytes are encoded under each index column's **own** key collation (the index's per-column `COLLATE`, else the table column's declared collation), and the constraint re-validates candidates under its enforcement collation `C` (the same resolution). For every index a designed path can hand the check the two are identical by construction, so the seek window is exactly the `C`-equal set. The check is kept because one undesigned path can still disagree — a user index whose name collides with the `_uc_*` a constraint would have minted lets a same-columns index with different collations be selected — and there a seek would under-fetch and silently accept a real duplicate, so the constraint falls back to the full scan. The **table key collation** `K` (the `collation` module option, default `NOCASE`) is not part of this decision; it is the fallback for an undecorated *primary-key* member only. "Can never hold text" (exempt — key bytes are type-native) is judged by physical representation, not by declared type name: an `ANY` or `JSON` column stores a string as a string, so neither is exempt — and because their `compare` ignores collation, they key hard-`BINARY`, so an `ANY`/`JSON` column carrying a declared `COLLATE` is exactly the shape that declines.
93
+
94
+ Whichever route runs, the conflicting row is re-validated identically: the row being written is excluded by primary key, each constrained column is compared under its enforcement collation (a `CREATE UNIQUE INDEX … (col COLLATE x)` enforces `x`, else the column's declared collation), and a partial constraint's predicate must hold on the candidate.
95
+
96
+ Because route 2 trusts the index store to hold an entry for every live row, `CREATE INDEX` populates the new index from the table's **effective** rows — committed rows merged with the open transaction's pending writes. A row inserted earlier in that transaction is therefore indexed, and participates in `CREATE UNIQUE INDEX`'s duplicate check, rather than being invisible to every later seek. Index entries are written outside the transaction coordinator, so a later `ROLLBACK` leaves entries for rows that no longer exist; every reader resolves an index entry to its live row and drops it when the row is gone or no longer matches, so a stale entry can never manufacture a result.
97
+
98
+ **Implicit per-constraint index (`_uc_*`).** Every non-derived `UNIQUE` — declared inline at `CREATE TABLE` or added by `ALTER TABLE … ADD CONSTRAINT` — is backed by a hidden secondary index named `<constraint name>` or, when unnamed, `_uc_<columns>` (the same convention the memory backend and the engine's `implicitIndexName` use). This is what makes route 2 reach a plain `UNIQUE`, so a bulk load no longer degrades to O(n²). The index is:
99
+
100
+ - **Kept out of the engine.** It lives only in the StoreTable's *enforcement* schema, never in the engine-registered schema, so the read-query planner does not see it (a plain `UNIQUE` gets no read-side plan from it — matching the memory backend) and it is never written to the catalog as a `CREATE INDEX`.
101
+ - **Derived on open.** Reconstructing a StoreTable re-materializes the schema entry from `uniqueConstraints`; the physical index store persists on disk under its deterministic name and is reopened lazily. (A store written *before* this feature has no `_uc_*` store on disk — backwards compatibility is waived project-wide; reopening such a database would need the index rebuilt.)
102
+ - **Reconciled across ALTER and CREATE / DROP INDEX.** `ADD CONSTRAINT` builds the physical store from the existing rows (after the existing-row duplicate check passes); `DROP CONSTRAINT` tears it down (so a later re-`ADD` cannot reopen stale entries); `RENAME CONSTRAINT`, and a column rename that changes an unnamed constraint's implicit name, move the store; a PK / collation / data-type `ALTER` re-encodes it via the same rebuild that re-encodes explicit indexes.
103
+ - **Relocated / reclaimed with the table.** `DROP TABLE` deletes the `_uc_*` store alongside the data + explicit-index stores; `RENAME TABLE` relocates it under the new name. Both resolve the physical store list from the *enforcement* schema (not the engine-facing `.indexes`, which omits `_uc_*`), so the implicit store is never stranded on drop nor left behind on rename (which would leave the renamed table seeking an empty store and accepting a duplicate).
104
+ - **Skipped when an explicit index already realizes the constraint.** A `UNIQUE` whose columns are covered — in the same order — by a full (non-partial) `CREATE INDEX` whose per-column collations match the declared column collations gets no `_uc_*` at all: that index enforces it, so a write maintains one structure instead of two. Reuse is refused for a partial index, a partial `UNIQUE`, an index over different or reordered columns, and a collation-mismatched index; those still build the `_uc_*` and both structures coexist. The decision is recomputed whenever the schema changes and is never persisted, so `CREATE INDEX` over a constrained column tears the redundant `_uc_*` store down and `DROP INDEX` rebuilds it from the live rows.
105
+ - **DDL-committing when it tears a store down.** Deleting a `_uc_*` store (or an explicit index store) discards a KVStore handle the transaction coordinator may hold buffered writes against, so those DDL statements flush the module's pending transaction first — the same `ddlTransactionality: 'auto-commit'` posture the row-rewriting `ALTER` arms take. A `CREATE INDEX` that retires no `_uc_*` does not flush.
106
+
107
+ ## Installation
108
+
109
+ ```bash
110
+ npm install @quereus/store
111
+ ```
112
+
113
+ For platform-specific implementations:
114
+ ```bash
115
+ # Node.js
116
+ npm install @quereus/plugin-leveldb
117
+
118
+ # Browser
119
+ npm install @quereus/plugin-indexeddb
120
+ ```
121
+
122
+ ## Usage
123
+
124
+ ### With a Provider
125
+
126
+ ```typescript
127
+ import { Database } from '@quereus/quereus';
128
+ import { StoreModule } from '@quereus/store';
129
+ import { createLevelDBProvider } from '@quereus/plugin-leveldb';
130
+ // OR: import { createIndexedDBProvider } from '@quereus/plugin-indexeddb';
131
+
132
+ const db = new Database();
133
+
134
+ // Create provider for your platform
135
+ const provider = createLevelDBProvider({ basePath: './data' });
136
+
137
+ // Create the generic store module with your provider
138
+ const storeModule = new StoreModule(provider);
139
+ db.registerModule('store', storeModule);
140
+
141
+ // Use it in SQL
142
+ await db.exec(`
143
+ create table users (id integer primary key, name text)
144
+ using store
145
+ `);
146
+ ```
147
+
148
+ ### Custom Storage Backend
149
+
150
+ Implement `KVStore` and `KVStoreProvider` to create custom storage backends:
151
+
152
+ ```typescript
153
+ import type { KVStore, KVStoreProvider } from '@quereus/store';
154
+
155
+ class MyCustomStore implements KVStore {
156
+ async get(key: Uint8Array) { /* ... */ }
157
+ async put(key: Uint8Array, value: Uint8Array) { /* ... */ }
158
+ async delete(key: Uint8Array) { /* ... */ }
159
+ async has(key: Uint8Array) { /* ... */ }
160
+ iterate(options?: IterateOptions) { /* ... */ }
161
+ batch() { /* ... */ }
162
+ async close() { /* ... */ }
163
+ async approximateCount(options?: IterateOptions) { /* ... */ }
164
+ }
165
+
166
+ class MyCustomProvider implements KVStoreProvider {
167
+ async getStore(schemaName: string, tableName: string) {
168
+ return new MyCustomStore(/* ... */);
169
+ }
170
+ async getIndexStore(schemaName: string, tableName: string, indexName: string) {
171
+ return new MyCustomStore(/* ... */);
172
+ }
173
+ async getStatsStore(schemaName: string, tableName: string) {
174
+ return new MyCustomStore(/* ... */);
175
+ }
176
+ async getCatalogStore() { /* ... */ }
177
+ async closeStore(schemaName: string, tableName: string) { /* ... */ }
178
+ async closeIndexStore(schemaName: string, tableName: string, indexName: string) { /* ... */ }
179
+ async closeAll() { /* ... */ }
180
+ }
181
+
182
+ // Use it with StoreModule
183
+ const provider = new MyCustomProvider();
184
+ const module = new StoreModule(provider);
185
+ db.registerModule('store', module);
186
+ ```
187
+
188
+ **Validate a new backend against the shared conformance suite.** `@quereus/store/testing`
189
+ exports `runKVStoreConformance(name, makeBackend)` — one parameterized battery of
190
+ behavioral tests written against the `KVStore` contract (point ops, ordering, range
191
+ iteration, streaming across page boundaries, batch semantics, optional persistence, and
192
+ cross-backend encoded-key ordering). Wire a tiny lifecycle adapter and run it under Mocha
193
+ so any drift from the contract fails a test:
194
+
195
+ ```typescript
196
+ import { runKVStoreConformance } from '@quereus/store/testing';
197
+
198
+ runKVStoreConformance('MyCustomStore', () => ({
199
+ open: async () => new MyCustomStore(/* ... */),
200
+ // Omit `reopen` for a non-persistent backend; supply it (reopen the SAME keyspace
201
+ // without wiping) to also exercise the persistence tier.
202
+ teardown: async () => { /* close handles, remove backing storage */ },
203
+ }));
204
+ ```
205
+
206
+ See `test/kv-conformance.spec.ts` (in-memory), and the LevelDB / IndexedDB plugins'
207
+ `test/conformance.spec.ts` for worked adapters.
208
+
209
+ **If your provider implements `beginAtomicBatch`, also run the provider suite.** The same
210
+ entry point exports `runKVProviderConformance(name, makeProviderBackend)` — the battery for
211
+ the provider-level contract that ops queued across SEVERAL of the provider's stores commit
212
+ in one durable, all-or-nothing physical write (multi-store commit landing only in each op's
213
+ own store, nothing visible until `write()`, mixed put + delete, a delete of a missing key,
214
+ same-key last-op-wins, `clear()` discarding, the empty `write()` no-op, and `MISUSE` on a
215
+ store handle from a different provider):
216
+
217
+ ```typescript
218
+ import { runKVProviderConformance } from '@quereus/store/testing';
219
+
220
+ runKVProviderConformance('MyCustomProvider atomic batch', () => ({
221
+ open: async () => new MyCustomProvider(/* fresh, empty keyspace */),
222
+ // A SECOND provider over a DIFFERENT keyspace — source of the foreign handle that
223
+ // must be rejected with MISUSE.
224
+ openForeign: async () => new MyCustomProvider(/* another keyspace */),
225
+ teardown: async () => { /* close both, remove backing storage */ },
226
+ }));
227
+ ```
228
+
229
+ The LevelDB / IndexedDB plugins' `test/atomic-batch.spec.ts` run it, keeping only their
230
+ backend-specific cases (LevelDB's "no batch before the shared root is open", IndexedDB's
231
+ post-write read-cache invalidation) alongside.
232
+
233
+ ## KVStore Interface
234
+
235
+ The `KVStore` interface is the foundation for all storage backends:
236
+
237
+ ```typescript
238
+ interface KVStore {
239
+ get(key: Uint8Array): Promise<Uint8Array | undefined>;
240
+ put(key: Uint8Array, value: Uint8Array): Promise<void>;
241
+ delete(key: Uint8Array): Promise<void>;
242
+ has(key: Uint8Array): Promise<boolean>;
243
+ iterate(options?: IterateOptions): AsyncIterable<KVEntry>;
244
+ batch(): WriteBatch;
245
+ close(): Promise<void>;
246
+ approximateCount(options?: IterateOptions): Promise<number>;
247
+ }
248
+
249
+ interface KVStoreProvider {
250
+ // Get data store for a table
251
+ getStore(schemaName: string, tableName: string): Promise<KVStore>;
252
+
253
+ // Get index store for a secondary index
254
+ getIndexStore(schemaName: string, tableName: string, indexName: string): Promise<KVStore>;
255
+
256
+ // Get stats store for table statistics
257
+ getStatsStore(schemaName: string, tableName: string): Promise<KVStore>;
258
+
259
+ // Get catalog store for DDL metadata
260
+ getCatalogStore(): Promise<KVStore>;
261
+
262
+ // Close specific stores
263
+ closeStore(schemaName: string, tableName: string): Promise<void>;
264
+ closeIndexStore(schemaName: string, tableName: string, indexName: string): Promise<void>;
265
+ closeAll(): Promise<void>;
266
+
267
+ // Optional: Delete stores. `indexNames` is the table's exact secondary-index
268
+ // names (from the schema) — build index store names from it via
269
+ // buildIndexStoreName instead of prefix-scanning `{table}_idx_`, which would
270
+ // also match a sibling table literally named `{table}_idx_<x>`.
271
+ deleteIndexStore?(schemaName: string, tableName: string, indexName: string): Promise<void>;
272
+ deleteTableStores?(schemaName: string, tableName: string, indexNames: readonly string[]): Promise<void>;
273
+
274
+ // Optional: Relocate a table's data + index stores for ALTER TABLE ... RENAME TO
275
+ // (`indexNames` carries the same authoritative, exact index list).
276
+ renameTableStores?(schemaName: string, oldName: string, newName: string, indexNames: readonly string[]): Promise<void>;
277
+ }
278
+ ```
279
+
280
+ ## Module Capabilities
281
+
282
+ The `StoreModule` reports its capabilities via `getCapabilities()`:
283
+
284
+ ```typescript
285
+ const storeModule = new StoreModule(provider);
286
+ const caps = storeModule.getCapabilities();
287
+
288
+ // {
289
+ // isolation: false, // Store module does NOT provide transaction isolation
290
+ // savepoints: true, // Coordinator-buffered ops support savepoints within a transaction
291
+ // persistent: true, // Data persists across restarts
292
+ // secondaryIndexes: true,// Supports secondary indexes
293
+ // rangeScans: true // Supports range scans
294
+ // }
295
+ ```
296
+
297
+ **Important:** The base `StoreModule` does not provide transaction isolation:
298
+ - No snapshot isolation: between connections, reads see only committed data, and concurrent readers may observe partial writes
299
+ - Within a transaction, reads through the table's shared coordinator DO see that transaction's own pending writes (read-your-own-writes). This extends to DML's own internal reads: the insert PK-conflict probe, the update/delete old-image reads, and the update PK-change conflict probe all read through the pending merge, so an INSERT/UPDATE/DELETE against a row written earlier in the same transaction sees that pending row — it raises a UNIQUE conflict (or evicts under `OR REPLACE`), cleans up secondary-index entries, tracks the correct row-count delta, and emits events carrying the pending `oldRow`
300
+ - Row-validating DDL reads the same effective stream: `create index` / `create unique index` populate from it (see above), and `alter table … add constraint … unique` plus the `set collate` re-validation of a covering non-PK UNIQUE scan it too — so a duplicate inserted earlier in the still-open transaction is rejected rather than surviving to commit
301
+ - Savepoints (create / release / rollback-to) work within a transaction via the coordinator's buffered op log
302
+ - Caveat: a DDL-commit operation (`replaceContents` / `renameTable`, e.g. `refresh materialized view` or `alter table … rename`) commits the coordinator mid-transaction, clearing the savepoint stack. A later `rollback to` / `release` targeting a now-vanished savepoint degrades to a no-op (warn-and-return) rather than throwing; the committed DDL and everything before it stays committed
303
+
304
+ ## Atomic multi-store commit (module-wide, cross-table)
305
+
306
+ A single `TransactionCoordinator` is shared by **every table of one storage
307
+ module** — it is the unit of cross-table atomicity. Every buffered op is
308
+ addressed by its explicit target `KVStore` handle (data ops, secondary-index
309
+ ops, and backing-host writes alike), so a transaction touching tables A and B
310
+ accumulates all of their stores' ops in one coordinator. Because the engine
311
+ commits virtual-table connections **sequentially** and the coordinator's
312
+ `commit()`/`rollback()` are **idempotent**, the first connection to commit
313
+ flushes **every** touched store of **every** table the transaction wrote; the
314
+ remaining connections no-op.
315
+
316
+ `TransactionCoordinator.commit()` thus writes each table's data store and each of
317
+ its secondary-index stores. By default it writes **one `KVStore.batch()` per
318
+ store, sequentially** — a crash between those batches can leave tables/indexes
319
+ divergent on disk, with no automatic healing (no worse than the prior per-table
320
+ commits, which were already non-atomic across tables).
321
+
322
+ A provider whose stores share a single durable commit domain can close that
323
+ window by implementing the optional `KVStoreProvider.beginAtomicBatch()`:
324
+
325
+ ```typescript
326
+ interface AtomicBatch {
327
+ put(store: KVStore, key: Uint8Array, value: Uint8Array): void;
328
+ delete(store: KVStore, key: Uint8Array): void;
329
+ write(): Promise<void>; // one durable, all-or-nothing physical commit
330
+ clear(): void;
331
+ }
332
+
333
+ interface KVStoreProvider {
334
+ // ...
335
+ // Open a batch spanning this provider's stores, or undefined when the provider
336
+ // has no shared commit domain (the coordinator then falls back to per-store batch()).
337
+ beginAtomicBatch?(): AtomicBatch | undefined;
338
+ }
339
+ ```
340
+
341
+ The batch addresses stores by **`KVStore` handle**, so it composes with the
342
+ coordinator's existing per-store bucketing without a name lookup. When present,
343
+ `commit()` queues every pending op — every store of **every table** in the
344
+ transaction — into one `AtomicBatch` and issues a single `write()`; all of those
345
+ tables commit or roll back together. When absent — or when the factory returns
346
+ `undefined` — behavior is byte-identical to the per-store loop, so providers
347
+ without a shared domain are unaffected.
348
+
349
+ The capability surface spans **multiple stores of one provider** (every store of
350
+ every table the module owns), giving full module-wide cross-table atomicity with
351
+ no interface change. The
352
+ [`@quereus/plugin-indexeddb`](../quereus-plugin-indexeddb) provider implements it
353
+ over its single IndexedDB database (multiple object stores, one
354
+ `db.transaction(...,'readwrite')`), invalidating each touched store's read cache
355
+ after a successful write so read-your-own-writes survives the cache.
356
+
357
+ ## Materialized-View Backing Host
358
+
359
+ The store module implements the engine's backing-host capability
360
+ (`StoreBackingHost`), so `create materialized view mv using store as <body>`
361
+ places the MV's backing table in persistent storage. Maintenance writes ride
362
+ the module's shared `TransactionCoordinator`'s pending state (committing/rolling
363
+ back in lockstep with the source write — and, since the coordinator is
364
+ module-wide, in the same all-or-nothing batch as a write to a same-module
365
+ source), mid-transaction reads of the MV see pending
366
+ maintenance through the read-your-own-writes merge, and the backing's text
367
+ primary-key columns are keyed under the store's `collation` arg (default
368
+ `NOCASE` — pass `using store(collation = 'BINARY')` for byte-exact keys). The
369
+ isolation wrapper forwards the capability automatically. See
370
+ [`docs/mv-backing-host.md` § The store host](../../docs/mv-backing-host.md#the-store-host-using-store).
371
+
372
+ ## External Row-Write Entry Point
373
+
374
+ `StoreTable.applyExternalRowChanges(ops)` applies trusted, externally-originated
375
+ row writes (e.g. inbound replication) directly to a **source** table's committed
376
+ storage — table-owned data-key put/delete, **secondary-index maintenance**, and
377
+ stats tracking — and returns the effective `BackingRowChange[]` (the shape
378
+ `Database.ingestExternalRowChanges` consumes). It is the index-maintaining
379
+ sibling of the backing host (whose MV backing tables carry no indexes): a caller
380
+ writing the data `KVStore` directly would silently skip index and stats upkeep.
381
+
382
+ Resolve the table with `StoreModule.getTableForExternalWrite(db, schema, table)`
383
+ (same ownership/wrapper resolution as `getBackingHost`), read a row's current
384
+ image with `StoreTable.readRowByPk(pk)`, then apply one `ExternalRowOp` per row:
385
+
386
+ ```typescript
387
+ const table = storeModule.getTableForExternalWrite(db, 'main', 'users');
388
+ if (table) {
389
+ const changes = await table.applyExternalRowChanges([
390
+ { op: 'upsert', row: [1, 'alice'] }, // full row, schema column order
391
+ { op: 'delete', pk: [2] }, // PK values, PK-definition order
392
+ ]);
393
+ }
394
+ ```
395
+
396
+ Deliberately emits **no** module data events (the caller owns emission and the
397
+ `remote` flag), opens **no** coordinator transaction (writes commit at once,
398
+ last-writer-wins against any pending local transaction), and runs **no**
399
+ constraint validation (the origin is trusted). No-ops are suppressed: a delete
400
+ of an absent key and a value-identical upsert (byte-faithful) write nothing and
401
+ report nothing.
402
+
403
+ ## Transaction Isolation
404
+
405
+ To add full ACID transaction semantics with snapshot isolation, wrap the store module with the `IsolationModule`:
406
+
407
+ ```typescript
408
+ import { Database, MemoryTableModule } from '@quereus/quereus';
409
+ import { IsolationModule } from '@quereus/isolation';
410
+ import { StoreModule, createIsolatedStoreModule } from '@quereus/store';
411
+ import { createLevelDBProvider } from '@quereus/plugin-leveldb';
412
+
413
+ const db = new Database();
414
+ const provider = createLevelDBProvider({ basePath: './data' });
415
+
416
+ // Option 1: Use the convenience function
417
+ const isolatedModule = createIsolatedStoreModule({ provider });
418
+ db.registerModule('store', isolatedModule);
419
+
420
+ // Option 2: Manual wrapping for more control
421
+ const storeModule = new StoreModule(provider);
422
+ const isolatedModule = new IsolationModule({
423
+ underlying: storeModule,
424
+ overlay: new MemoryTableModule(),
425
+ });
426
+ db.registerModule('store', isolatedModule);
427
+
428
+ // Now transactions have full isolation
429
+ await db.exec('BEGIN');
430
+ await db.exec(`INSERT INTO users VALUES (1, 'Alice')`);
431
+
432
+ // Read-your-own-writes: sees uncommitted insert
433
+ const user = await db.get('SELECT * FROM users WHERE id = 1');
434
+ console.log(user.name); // 'Alice'
435
+
436
+ await db.exec('COMMIT'); // Or ROLLBACK to discard
437
+ ```
438
+
439
+ The isolation layer provides:
440
+ - **Read-your-own-writes** within transactions
441
+ - **Snapshot isolation** for consistent reads
442
+ - **Savepoint support** via the overlay module
443
+
444
+ ### Checking for Isolation Support
445
+
446
+ ```typescript
447
+ import { hasIsolation } from '@quereus/store';
448
+
449
+ const storeModule = new StoreModule(provider);
450
+ console.log(hasIsolation(storeModule)); // false
451
+
452
+ const isolatedModule = createIsolatedStoreModule({ provider });
453
+ console.log(hasIsolation(isolatedModule)); // true
454
+ ```
455
+
456
+ ## API
457
+
458
+ ### Core Exports
459
+
460
+ | Export | Description |
461
+ |--------|-------------|
462
+ | `KVStore` | Key-value store interface (type) |
463
+ | `KVStoreProvider` | Store factory interface (type) |
464
+ | `WriteBatch` | Batch write interface (type) |
465
+ | `AtomicBatch` | Cross-store all-or-nothing batch from `KVStoreProvider.beginAtomicBatch` (type) |
466
+ | `IterateOptions` | Iteration options (type) |
467
+ | `StoreModule` | Generic VirtualTableModule |
468
+ | `StoreTable` | Virtual table implementation (incl. `applyExternalRowChanges` / `readRowByPk` for externally-applied source writes) |
469
+ | `ExternalRowOp` | One externally-applied row op (`upsert`/`delete`) for `StoreTable.applyExternalRowChanges` (type) |
470
+ | `resolvePkKeyCollations` | Per-PK-column key collations (pass to `buildDataKey`/`buildIndexKey` to match `StoreTable`'s key bytes) |
471
+ | `resolveIndexKeyCollations` | Per-index-column key collations (the index half of `buildIndexKey`: index COLLATE ?? column collation ?? BINARY) |
472
+ | `StoreConnection` | Transaction connection |
473
+ | `TransactionCoordinator` | Transaction management |
474
+ | `StoreEventEmitter` | Event system for data/schema changes |
475
+
476
+ ### Isolation Layer Utilities
477
+
478
+ | Export | Description |
479
+ |--------|-------------|
480
+ | `createIsolatedStoreModule` | Create store module with isolation layer |
481
+ | `hasIsolation` | Check if a module has isolation capability |
482
+ | `IsolatedStoreModuleConfig` | Configuration for isolated store module |
483
+
484
+ ### Caching
485
+
486
+ | Export | Description |
487
+ |--------|-------------|
488
+ | `CachedKVStore` | Read-through LRU cache wrapper for any `KVStore` |
489
+ | `CacheOptions` | Configuration for cache (maxEntries, maxBytes, enabled) |
490
+
491
+ ### Encoding Utilities
492
+
493
+ | Export | Description |
494
+ |--------|-------------|
495
+ | `encodeValue` | Encode a SQL value to sortable bytes |
496
+ | `decodeValue` | Decode bytes back to SQL value |
497
+ | `encodeCompositeKey` | Encode multiple values as composite key |
498
+ | `decodeCompositeKey` | Decode composite key to values |
499
+ | `BUILTIN_KEY_NORMALIZER_RESOLVER` | Built-ins-only key-normalizer resolver (`EncodeOptions.normalizers` default) |
500
+
501
+ ### Serialization Utilities
502
+
503
+ | Export | Description |
504
+ |--------|-------------|
505
+ | `serializeRow` | Serialize a row to bytes |
506
+ | `deserializeRow` | Deserialize bytes to row |
507
+ | `serializeValue` | Serialize a single value |
508
+ | `deserializeValue` | Deserialize a single value |
509
+
510
+ ### Key Building
511
+
512
+ | Export | Description |
513
+ |--------|-------------|
514
+ | `buildDataStoreName` | Build store name for table data |
515
+ | `buildIndexStoreName` | Build store name for an index |
516
+ | `buildStatsStoreName` | Build store name for table stats |
517
+ | `buildDataKey` | Build key for row data (encoded PK) |
518
+ | `buildIndexKey` | Build key for index entry |
519
+ | `buildCatalogKey` | Build key for a table's catalog entry (`{schema}.{table}`) |
520
+ | `buildViewCatalogKey` | Build key for a view's catalog entry (reserved `\x00view\x00` prefix) |
521
+ | `buildMaterializedViewCatalogKey` | Build key for an MV's catalog entry (reserved `\x00mview\x00` prefix) |
522
+ | `classifyCatalogKey` | Classify a loaded catalog key as `'table'` / `'view'` / `'materializedView'` |
523
+ | `buildFullScanBounds` | Build bounds for full table scan |
524
+ | `buildIndexPrefixBounds` | Build bounds for index prefix scan |
525
+ | `buildPkPrefixBounds` | Build bounds for a data-store PK prefix range (per-column DESC + key collations) |
526
+ | `buildCatalogScanBounds` | Build bounds for catalog scan |
527
+ | `CATALOG_STORE_NAME` | Reserved catalog store name constant |
528
+ | `STORE_SUFFIX` | Store name suffixes (INDEX, STATS) |
529
+
530
+ ## Related Packages
531
+
532
+ - [`@quereus/plugin-leveldb`](../quereus-plugin-leveldb/) - LevelDB implementation for Node.js
533
+ - [`@quereus/plugin-indexeddb`](../quereus-plugin-indexeddb/) - IndexedDB implementation for browsers
534
+ - [`@quereus/sync`](../quereus-sync/) - CRDT sync layer
535
+
536
+ ## License
537
+
538
+ MIT