@b2bc-devkit/sheetorm 1.1.0 → 1.2.3

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 (150) hide show
  1. package/package.json +171 -86
  2. package/readme.md +57 -17
  3. package/dist/npm/SheetOrm.d.ts +0 -41
  4. package/dist/npm/SheetOrm.d.ts.map +0 -1
  5. package/dist/npm/SheetOrm.js +0 -41
  6. package/dist/npm/SheetOrm.js.map +0 -1
  7. package/dist/npm/core/Decorators.d.ts +0 -145
  8. package/dist/npm/core/Decorators.d.ts.map +0 -1
  9. package/dist/npm/core/Decorators.js +0 -226
  10. package/dist/npm/core/Decorators.js.map +0 -1
  11. package/dist/npm/core/Record.d.ts +0 -178
  12. package/dist/npm/core/Record.d.ts.map +0 -1
  13. package/dist/npm/core/Record.js +0 -313
  14. package/dist/npm/core/Record.js.map +0 -1
  15. package/dist/npm/core/RecordConstructor.d.ts +0 -20
  16. package/dist/npm/core/RecordConstructor.d.ts.map +0 -1
  17. package/dist/npm/core/RecordConstructor.js +0 -2
  18. package/dist/npm/core/RecordConstructor.js.map +0 -1
  19. package/dist/npm/core/RecordStatic.d.ts +0 -23
  20. package/dist/npm/core/RecordStatic.d.ts.map +0 -1
  21. package/dist/npm/core/RecordStatic.js +0 -2
  22. package/dist/npm/core/RecordStatic.js.map +0 -1
  23. package/dist/npm/core/Registry.d.ts +0 -101
  24. package/dist/npm/core/Registry.d.ts.map +0 -1
  25. package/dist/npm/core/Registry.js +0 -226
  26. package/dist/npm/core/Registry.js.map +0 -1
  27. package/dist/npm/core/SheetRepository.d.ts +0 -331
  28. package/dist/npm/core/SheetRepository.d.ts.map +0 -1
  29. package/dist/npm/core/SheetRepository.js +0 -1041
  30. package/dist/npm/core/SheetRepository.js.map +0 -1
  31. package/dist/npm/core/cache/MemoryCache.d.ts +0 -67
  32. package/dist/npm/core/cache/MemoryCache.d.ts.map +0 -1
  33. package/dist/npm/core/cache/MemoryCache.js +0 -112
  34. package/dist/npm/core/cache/MemoryCache.js.map +0 -1
  35. package/dist/npm/core/types/Entity.d.ts +0 -21
  36. package/dist/npm/core/types/Entity.d.ts.map +0 -1
  37. package/dist/npm/core/types/Entity.js +0 -2
  38. package/dist/npm/core/types/Entity.js.map +0 -1
  39. package/dist/npm/core/types/FieldDefinition.d.ts +0 -21
  40. package/dist/npm/core/types/FieldDefinition.d.ts.map +0 -1
  41. package/dist/npm/core/types/FieldDefinition.js +0 -2
  42. package/dist/npm/core/types/FieldDefinition.js.map +0 -1
  43. package/dist/npm/core/types/FieldType.d.ts +0 -12
  44. package/dist/npm/core/types/FieldType.d.ts.map +0 -1
  45. package/dist/npm/core/types/FieldType.js +0 -2
  46. package/dist/npm/core/types/FieldType.js.map +0 -1
  47. package/dist/npm/core/types/Filter.d.ts +0 -16
  48. package/dist/npm/core/types/Filter.d.ts.map +0 -1
  49. package/dist/npm/core/types/Filter.js +0 -2
  50. package/dist/npm/core/types/Filter.js.map +0 -1
  51. package/dist/npm/core/types/FilterOperator.d.ts +0 -16
  52. package/dist/npm/core/types/FilterOperator.d.ts.map +0 -1
  53. package/dist/npm/core/types/FilterOperator.js +0 -2
  54. package/dist/npm/core/types/FilterOperator.js.map +0 -1
  55. package/dist/npm/core/types/GroupResult.d.ts +0 -14
  56. package/dist/npm/core/types/GroupResult.d.ts.map +0 -1
  57. package/dist/npm/core/types/GroupResult.js +0 -2
  58. package/dist/npm/core/types/GroupResult.js.map +0 -1
  59. package/dist/npm/core/types/ICacheProvider.d.ts +0 -20
  60. package/dist/npm/core/types/ICacheProvider.d.ts.map +0 -1
  61. package/dist/npm/core/types/ICacheProvider.js +0 -2
  62. package/dist/npm/core/types/ICacheProvider.js.map +0 -1
  63. package/dist/npm/core/types/ISheetAdapter.d.ts +0 -53
  64. package/dist/npm/core/types/ISheetAdapter.d.ts.map +0 -1
  65. package/dist/npm/core/types/ISheetAdapter.js +0 -2
  66. package/dist/npm/core/types/ISheetAdapter.js.map +0 -1
  67. package/dist/npm/core/types/ISpreadsheetAdapter.d.ts +0 -41
  68. package/dist/npm/core/types/ISpreadsheetAdapter.d.ts.map +0 -1
  69. package/dist/npm/core/types/ISpreadsheetAdapter.js +0 -2
  70. package/dist/npm/core/types/ISpreadsheetAdapter.js.map +0 -1
  71. package/dist/npm/core/types/IndexDefinition.d.ts +0 -16
  72. package/dist/npm/core/types/IndexDefinition.d.ts.map +0 -1
  73. package/dist/npm/core/types/IndexDefinition.js +0 -2
  74. package/dist/npm/core/types/IndexDefinition.js.map +0 -1
  75. package/dist/npm/core/types/LifecycleHooks.d.ts +0 -32
  76. package/dist/npm/core/types/LifecycleHooks.d.ts.map +0 -1
  77. package/dist/npm/core/types/LifecycleHooks.js +0 -2
  78. package/dist/npm/core/types/LifecycleHooks.js.map +0 -1
  79. package/dist/npm/core/types/PaginatedResult.d.ts +0 -18
  80. package/dist/npm/core/types/PaginatedResult.d.ts.map +0 -1
  81. package/dist/npm/core/types/PaginatedResult.js +0 -2
  82. package/dist/npm/core/types/PaginatedResult.js.map +0 -1
  83. package/dist/npm/core/types/QueryOptions.d.ts +0 -21
  84. package/dist/npm/core/types/QueryOptions.d.ts.map +0 -1
  85. package/dist/npm/core/types/QueryOptions.js +0 -2
  86. package/dist/npm/core/types/QueryOptions.js.map +0 -1
  87. package/dist/npm/core/types/SortClause.d.ts +0 -10
  88. package/dist/npm/core/types/SortClause.d.ts.map +0 -1
  89. package/dist/npm/core/types/SortClause.js +0 -2
  90. package/dist/npm/core/types/SortClause.js.map +0 -1
  91. package/dist/npm/core/types/SystemColumns.d.ts +0 -16
  92. package/dist/npm/core/types/SystemColumns.d.ts.map +0 -1
  93. package/dist/npm/core/types/SystemColumns.js +0 -16
  94. package/dist/npm/core/types/SystemColumns.js.map +0 -1
  95. package/dist/npm/core/types/TableSchema.d.ts +0 -24
  96. package/dist/npm/core/types/TableSchema.d.ts.map +0 -1
  97. package/dist/npm/core/types/TableSchema.js +0 -2
  98. package/dist/npm/core/types/TableSchema.js.map +0 -1
  99. package/dist/npm/index/IndexMeta.d.ts +0 -16
  100. package/dist/npm/index/IndexMeta.d.ts.map +0 -1
  101. package/dist/npm/index/IndexMeta.js +0 -2
  102. package/dist/npm/index/IndexMeta.js.map +0 -1
  103. package/dist/npm/index/IndexStore.d.ts +0 -397
  104. package/dist/npm/index/IndexStore.d.ts.map +0 -1
  105. package/dist/npm/index/IndexStore.js +0 -1102
  106. package/dist/npm/index/IndexStore.js.map +0 -1
  107. package/dist/npm/index.d.ts +0 -115
  108. package/dist/npm/index.d.ts.map +0 -1
  109. package/dist/npm/index.js +0 -367
  110. package/dist/npm/index.js.map +0 -1
  111. package/dist/npm/query/Query.d.ts +0 -176
  112. package/dist/npm/query/Query.d.ts.map +0 -1
  113. package/dist/npm/query/Query.js +0 -270
  114. package/dist/npm/query/Query.js.map +0 -1
  115. package/dist/npm/query/QueryEngine.d.ts +0 -140
  116. package/dist/npm/query/QueryEngine.d.ts.map +0 -1
  117. package/dist/npm/query/QueryEngine.js +0 -453
  118. package/dist/npm/query/QueryEngine.js.map +0 -1
  119. package/dist/npm/storage/GoogleSheetAdapter.d.ts +0 -119
  120. package/dist/npm/storage/GoogleSheetAdapter.d.ts.map +0 -1
  121. package/dist/npm/storage/GoogleSheetAdapter.js +0 -238
  122. package/dist/npm/storage/GoogleSheetAdapter.js.map +0 -1
  123. package/dist/npm/storage/GoogleSpreadsheetAdapter.d.ts +0 -65
  124. package/dist/npm/storage/GoogleSpreadsheetAdapter.d.ts.map +0 -1
  125. package/dist/npm/storage/GoogleSpreadsheetAdapter.js +0 -119
  126. package/dist/npm/storage/GoogleSpreadsheetAdapter.js.map +0 -1
  127. package/dist/npm/testing/ParityCatalog.d.ts +0 -38
  128. package/dist/npm/testing/ParityCatalog.d.ts.map +0 -1
  129. package/dist/npm/testing/ParityCatalog.js +0 -404
  130. package/dist/npm/testing/ParityCatalog.js.map +0 -1
  131. package/dist/npm/testing/RuntimeBenchmark.d.ts +0 -37
  132. package/dist/npm/testing/RuntimeBenchmark.d.ts.map +0 -1
  133. package/dist/npm/testing/RuntimeBenchmark.js +0 -449
  134. package/dist/npm/testing/RuntimeBenchmark.js.map +0 -1
  135. package/dist/npm/testing/RuntimeParity.d.ts +0 -62
  136. package/dist/npm/testing/RuntimeParity.d.ts.map +0 -1
  137. package/dist/npm/testing/RuntimeParity.js +0 -4126
  138. package/dist/npm/testing/RuntimeParity.js.map +0 -1
  139. package/dist/npm/utils/Serialization.d.ts +0 -111
  140. package/dist/npm/utils/Serialization.d.ts.map +0 -1
  141. package/dist/npm/utils/Serialization.js +0 -281
  142. package/dist/npm/utils/Serialization.js.map +0 -1
  143. package/dist/npm/utils/SheetOrmLogger.d.ts +0 -31
  144. package/dist/npm/utils/SheetOrmLogger.d.ts.map +0 -1
  145. package/dist/npm/utils/SheetOrmLogger.js +0 -42
  146. package/dist/npm/utils/SheetOrmLogger.js.map +0 -1
  147. package/dist/npm/utils/Uuid.d.ts +0 -29
  148. package/dist/npm/utils/Uuid.d.ts.map +0 -1
  149. package/dist/npm/utils/Uuid.js +0 -86
  150. package/dist/npm/utils/Uuid.js.map +0 -1
@@ -1,1102 +0,0 @@
1
- /**
2
- * IndexStore — secondary index manager for SheetORM.
3
- *
4
- * Manages per-class index sheets that accelerate lookup and full-text
5
- * search operations. Each Record class with `@Indexed` fields gets a
6
- * single index sheet named `idx_{ClassName}s` (e.g. `idx_Cars`).
7
- *
8
- * **Sheet layout** (3-column, no header row):
9
- * ```
10
- * Col A (field) Col B (value) Col C (entityId)
11
- * "brand" "Toyota" "abc-123"
12
- * "model" "Corolla" "abc-123"
13
- * ```
14
- *
15
- * **Search strategy**: Solr-like n-gram search (trigram by default).
16
- * Text is normalised → tokenised → trigram-indexed. Queries intersect
17
- * posting lists and verify candidates with a substring match.
18
- *
19
- * **Performance optimisations** (codenames reference the B-series and
20
- * C-series GAS API reduction work):
21
- *
22
- * - **B3/B5**: Known row count avoids `getAllData()` on the write path.
23
- * - **B6**: Empty-table detection skips `getAllData()` entirely.
24
- * - **C1**: `createCombinedIndex` seeds `indexRowCount` with one
25
- * `getLastRow()` call so subsequent appends never need a full read.
26
- * - **J1**: Newly created index sheets skip `setHeaders()` — column
27
- * positions are hard-coded (0=field, 1=value, 2=entityId).
28
- * - Sheet references are memoised in `indexSheetCache` to avoid
29
- * repeated `getSheetByName()` API calls (~300 ms each in GAS).
30
- * - Batch mode (`beginIndexBatch` / `flushIndexBatch`) accumulates
31
- * all index writes in memory and flushes them in a single
32
- * `writeRowsAt()` call per index table.
33
- *
34
- * @module IndexStore
35
- */
36
- import { SheetOrmLogger } from "../utils/SheetOrmLogger.js";
37
- /**
38
- * Manages secondary index sheets and provides n-gram text search.
39
- *
40
- * One IndexStore instance is owned by a {@link Registry} and shared
41
- * across all SheetRepository instances created from that registry.
42
- */
43
- export class IndexStore {
44
- /**
45
- * @param adapter - Spreadsheet adapter for sheet-level operations.
46
- * @param cache - Optional cache provider (e.g. MemoryCache) for index data.
47
- */
48
- constructor(adapter, cache) {
49
- /** Registry of all declared indexes: key = "tableName::field" → IndexMeta. */
50
- this.indexRegistry = new Map();
51
- /** Lazy-built n-gram search indexes, keyed by "tableName::field". */
52
- this.searchIndexCache = new Map();
53
- /** Pending batch writes (tableName → rows), or null when batch mode is off. */
54
- this.indexBatch = null;
55
- /** Memoised sheet references — avoids duplicate getSheetByName() API calls within a session. */
56
- this.indexSheetCache = new Map();
57
- /** Tracks known row count per index table — avoids full getAllData() reads to determine append position. */
58
- this.indexRowCount = new Map();
59
- this.adapter = adapter;
60
- this.cache = cache ?? null;
61
- }
62
- /**
63
- * Build a composite registry key for a (tableName, field) pair.
64
- * Used as the key in `indexRegistry` and for search cache invalidation.
65
- */
66
- registryKey(tableName, field) {
67
- return `${tableName}::${field}`;
68
- }
69
- /**
70
- * Retrieve all registered {@link IndexMeta} entries for a given table.
71
- *
72
- * @param tableName - Index table name (e.g. `"idx_Cars"`).
73
- * @returns Array of IndexMeta for every indexed field in that table.
74
- */
75
- getIndexedFields(tableName) {
76
- const result = [];
77
- for (const meta of this.indexRegistry.values()) {
78
- if (meta.tableName === tableName) {
79
- result.push(meta);
80
- }
81
- }
82
- return result;
83
- }
84
- /**
85
- * Register an indexed field during schema initialisation.
86
- *
87
- * Called by {@link Registry.registerClass} for each `@Indexed` decorator
88
- * found on a Record subclass.
89
- *
90
- * @param tableName - Index table name (e.g. `"idx_Cars"`).
91
- * @param field - Field name to index (e.g. `"brand"`).
92
- * @param unique - Whether the index enforces uniqueness.
93
- */
94
- registerIndex(tableName, field, unique) {
95
- this.indexRegistry.set(this.registryKey(tableName, field), {
96
- tableName,
97
- field,
98
- unique,
99
- });
100
- }
101
- // ─── Batch index write buffering ────────────────────────────────────────────
102
- // When batch mode is active (`indexBatch !== null`), all index writes are
103
- // accumulated in an in-memory Map instead of hitting the sheet immediately.
104
- // `flushIndexBatch()` writes all buffered rows in a single `writeRowsAt()`
105
- // call per index table, reducing GAS API round-trips from N to 1.
106
- /**
107
- * Activate batch mode — subsequent `addAllFieldsToCombined` calls will
108
- * buffer rows instead of writing to the sheet.
109
- *
110
- * Must be paired with either {@link flushIndexBatch} (commit) or
111
- * {@link cancelIndexBatch} (rollback).
112
- */
113
- beginIndexBatch() {
114
- this.indexBatch = new Map();
115
- SheetOrmLogger.log(`[Index] beginIndexBatch`);
116
- }
117
- /**
118
- * Flush all buffered index rows to their respective index sheets.
119
- *
120
- * For each index table with pending rows:
121
- * - **With cache**: reads combined data, appends rows via `writeRowsAt`,
122
- * and updates the in-memory cache array.
123
- * - **Without cache (B5)**: uses the known row count to write at the
124
- * correct offset, or falls back to `appendRows`.
125
- *
126
- * After flushing, batch mode is deactivated and the search index cache
127
- * is invalidated for affected tables.
128
- */
129
- flushIndexBatch() {
130
- if (!this.indexBatch)
131
- return;
132
- const batch = this.indexBatch;
133
- this.indexBatch = null; // Deactivate batch mode before writing
134
- for (const [indexTableName, rows] of batch) {
135
- if (rows.length === 0)
136
- continue;
137
- const sheet = this.getIndexSheet(indexTableName);
138
- if (!sheet)
139
- continue;
140
- SheetOrmLogger.log(`[Index:${indexTableName}] flushIndexBatch ${rows.length} rows`);
141
- if (this.cache) {
142
- // Cache path: read existing data, append new rows, update cache
143
- const data = this.getCombinedData(indexTableName);
144
- sheet.writeRowsAt(data.length, rows);
145
- for (const row of rows)
146
- data.push(row);
147
- this.indexRowCount.set(indexTableName, data.length);
148
- this.invalidateSearchCacheForTable(indexTableName);
149
- }
150
- else {
151
- // No-cache path: use known row count to avoid getAllData()
152
- const knownCount = this.indexRowCount.get(indexTableName);
153
- if (knownCount !== undefined) {
154
- sheet.writeRowsAt(knownCount, rows);
155
- this.indexRowCount.set(indexTableName, knownCount + rows.length);
156
- }
157
- else {
158
- // Fallback: let the adapter append (position unknown)
159
- sheet.appendRows(rows);
160
- }
161
- this.invalidateSearchCacheForTable(indexTableName);
162
- }
163
- }
164
- }
165
- /**
166
- * Discard all buffered index rows without writing (error/rollback path).
167
- */
168
- cancelIndexBatch() {
169
- SheetOrmLogger.log(`[Index] cancelIndexBatch`);
170
- this.indexBatch = null;
171
- }
172
- /**
173
- * Return a memoised sheet reference for the given index table name.
174
- *
175
- * On the first call for a table name, delegates to `adapter.getSheetByName()`
176
- * (a GAS API call costing ~300 ms). Subsequent calls return the cached
177
- * reference from `indexSheetCache`.
178
- *
179
- * @param indexTableName - Sheet name (e.g. `"idx_Cars"`).
180
- * @returns Sheet adapter, or `null` if the sheet does not exist.
181
- */
182
- getIndexSheet(indexTableName) {
183
- const cached = this.indexSheetCache.get(indexTableName);
184
- if (cached !== undefined)
185
- return cached;
186
- const sheet = this.adapter.getSheetByName(indexTableName);
187
- if (sheet)
188
- this.indexSheetCache.set(indexTableName, sheet);
189
- return sheet;
190
- }
191
- // ─── Combined (per-class) index sheet methods ───────────────────────────────
192
- // Each Record class with @Indexed fields gets ONE index sheet named
193
- // idx_{ClassName}s. All indexed fields for that class share the sheet,
194
- // distinguished by column A ("field").
195
- /**
196
- * Create or recognise the combined index sheet for a Record class.
197
- *
198
- * - If the sheet does **not** exist: creates it via `insertSheet()` and
199
- * skips the header row (no header row needed in index sheets).
200
- * - If the sheet **already** exists: seeds `indexRowCount` with a single
201
- * `getLastRow()` call so subsequent writes can append without reading all data.
202
- *
203
- * The caller can pass a pre-loaded sheet reference or `null` (confirmed
204
- * non-existent) to avoid a redundant `getSheetByName()` lookup.
205
- *
206
- * @param indexTableName - Sheet name (e.g. `"idx_Cars"`).
207
- * @param preloadedSheet - Optional: already-fetched sheet, or `null` to
208
- * signal the sheet definitely does not exist.
209
- */
210
- createCombinedIndex(indexTableName, preloadedSheet) {
211
- // undefined = not provided (fall back to getSheetByName)
212
- // null = caller confirmed the sheet does not exist (skip getSheetByName, go straight to insertSheet)
213
- // ISheetAdapter = use this sheet directly
214
- const existing = preloadedSheet !== undefined ? preloadedSheet : this.adapter.getSheetByName(indexTableName);
215
- if (!existing) {
216
- // Create new index sheet — no header row needed (column positions are hard-coded)
217
- const sheet = this.adapter.insertSheet(indexTableName);
218
- this.indexSheetCache.set(indexTableName, sheet);
219
- this.indexRowCount.set(indexTableName, 0); // Brand new sheet has 0 data rows
220
- SheetOrmLogger.log(`[Index] createCombinedIndex "${indexTableName}" → insertSheet (J1 no-header) rowCount=0`);
221
- }
222
- else {
223
- // Sheet already exists — seed row count with one getLastRow() call
224
- this.indexSheetCache.set(indexTableName, existing);
225
- const rowCount = existing.getRowCount();
226
- this.indexRowCount.set(indexTableName, rowCount);
227
- SheetOrmLogger.log(`[Index] createCombinedIndex "${indexTableName}" → existing (C1) rowCount=${rowCount}`);
228
- }
229
- }
230
- /**
231
- * Check whether a combined index sheet exists for the given table name.
232
- * Uses the memoised sheet cache when available.
233
- */
234
- existsCombined(indexTableName) {
235
- return this.getIndexSheet(indexTableName) !== null;
236
- }
237
- /**
238
- * Add a single (field, value, entityId) entry to the combined index sheet.
239
- *
240
- * Enforces unique index constraints: if a unique index already contains
241
- * the value for a different entity, throws an error.
242
- *
243
- * @param indexTableName - Sheet name.
244
- * @param field - Indexed field name.
245
- * @param value - Field value to index.
246
- * @param entityId - Owning entity's UUID.
247
- * @throws Error on unique index violation.
248
- */
249
- addToCombined(indexTableName, field, value, entityId) {
250
- const meta = this.indexRegistry.get(this.registryKey(indexTableName, field));
251
- const sheet = this.getIndexSheet(indexTableName);
252
- if (!sheet)
253
- return;
254
- const valueStr = String(value);
255
- // Unique constraint check: scan existing data for duplicate values
256
- if (meta?.unique) {
257
- const data = this.getCombinedData(indexTableName);
258
- for (let i = 0; i < data.length; i++) {
259
- if (String(data[i][0]) === field && String(data[i][1]) === valueStr) {
260
- if (String(data[i][2]) !== entityId) {
261
- throw new Error(`Unique index violation: ${indexTableName}.${field} already has value "${valueStr}" for entity ${String(data[i][2])}`);
262
- }
263
- // Same entity already indexed with this value — no-op
264
- return;
265
- }
266
- }
267
- }
268
- const newRow = [field, valueStr, entityId];
269
- if (this.cache) {
270
- // Cache path: append to both the sheet and the in-memory cache array
271
- const data = this.getCombinedData(indexTableName);
272
- sheet.writeRowsAt(data.length, [newRow]);
273
- data.push(newRow);
274
- this.indexRowCount.set(indexTableName, data.length);
275
- this.searchIndexCache.delete(`${this.registryKey(indexTableName, field)}`);
276
- }
277
- else {
278
- // No-cache path: use known row count or fall back to appendRow
279
- const knownCount = this.indexRowCount.get(indexTableName);
280
- if (knownCount !== undefined) {
281
- sheet.writeRowsAt(knownCount, [newRow]);
282
- this.indexRowCount.set(indexTableName, knownCount + 1);
283
- }
284
- else {
285
- sheet.appendRow(newRow);
286
- }
287
- this.invalidateSearchCacheForTable(indexTableName);
288
- }
289
- }
290
- /**
291
- * Add index entries for **all** indexed fields of a single entity in one
292
- * batch operation. Reduces N separate `appendRow()` API calls to a single
293
- * `writeRowsAt()` call (or buffers them if batch mode is active).
294
- *
295
- * Unique constraint checks are performed against both the existing sheet
296
- * data AND any pending (unflushed) batch entries.
297
- *
298
- * @param indexTableName - Sheet name.
299
- * @param entries - Array of { field, value } pairs to index.
300
- * @param entityId - Owning entity's UUID.
301
- * @throws Error on unique index violation.
302
- */
303
- addAllFieldsToCombined(indexTableName, entries, entityId) {
304
- // Do NOT call getSheetByName here — in batch mode the sheet is never needed
305
- // for the write path; fetching it per-entity causes 1000× GAS API calls.
306
- const rows = [];
307
- let data = null;
308
- for (const { field, value } of entries) {
309
- const valueStr = String(value);
310
- const meta = this.indexRegistry.get(this.registryKey(indexTableName, field));
311
- // Unique constraint check: scan existing data + pending batch entries
312
- if (meta?.unique) {
313
- if (!data)
314
- data = this.getCombinedData(indexTableName);
315
- let alreadyIndexed = false;
316
- // Check existing (committed) sheet data
317
- for (let i = 0; i < data.length; i++) {
318
- if (String(data[i][0]) === field && String(data[i][1]) === valueStr) {
319
- if (String(data[i][2]) !== entityId) {
320
- throw new Error(`Unique index violation: ${indexTableName}.${field} already has value "${valueStr}" for entity ${String(data[i][2])}`);
321
- }
322
- alreadyIndexed = true;
323
- break;
324
- }
325
- }
326
- // Also check pending (unflushed) batch entries for the same table
327
- if (!alreadyIndexed && this.indexBatch !== null) {
328
- const pending = this.indexBatch.get(indexTableName);
329
- if (pending) {
330
- for (let i = 0; i < pending.length; i++) {
331
- if (String(pending[i][0]) === field && String(pending[i][1]) === valueStr) {
332
- if (String(pending[i][2]) !== entityId) {
333
- throw new Error(`Unique index violation: ${indexTableName}.${field} already has value "${valueStr}" for entity ${String(pending[i][2])}`);
334
- }
335
- alreadyIndexed = true;
336
- break;
337
- }
338
- }
339
- }
340
- }
341
- if (alreadyIndexed)
342
- continue; // Value already indexed for this entity — skip
343
- }
344
- rows.push([field, valueStr, entityId]);
345
- }
346
- if (rows.length > 0) {
347
- if (this.indexBatch !== null) {
348
- // ── Batch mode: accumulate rows in memory, no sheet API call needed ──
349
- let pending = this.indexBatch.get(indexTableName);
350
- if (!pending) {
351
- pending = [];
352
- this.indexBatch.set(indexTableName, pending);
353
- }
354
- for (const row of rows)
355
- pending.push(row);
356
- return;
357
- }
358
- // ── Non-batch mode: write to sheet immediately ──
359
- SheetOrmLogger.log(`[Index:${indexTableName}] addAllFieldsToCombined — non-batch, writing ${rows.length} rows entity=${entityId.slice(0, 8)}`);
360
- if (this.cache) {
361
- // Cache-enabled path: tries three strategies in order of cheapness:
362
- // 1. Cache HIT → use cached array length as append offset
363
- // 2. Seeded row count → previously seeded by createCombinedIndex
364
- // 3. Fallback → full getAllData() read
365
- const sheet = this.getIndexSheet(indexTableName);
366
- if (!sheet)
367
- return;
368
- const cacheKey = `cidx:${indexTableName}`;
369
- const cachedData = this.cache.get(cacheKey);
370
- if (cachedData !== null) {
371
- // Strategy 1: cache HIT — append at cached length
372
- sheet.writeRowsAt(cachedData.length, rows);
373
- for (const row of rows)
374
- cachedData.push(row);
375
- this.indexRowCount.set(indexTableName, cachedData.length);
376
- }
377
- else {
378
- const knownCount = this.indexRowCount.get(indexTableName);
379
- if (knownCount !== undefined) {
380
- // Strategy 2 (C1): row count known — skip getAllData()
381
- sheet.writeRowsAt(knownCount, rows);
382
- this.indexRowCount.set(indexTableName, knownCount + rows.length);
383
- }
384
- else {
385
- // Strategy 3: fallback — full read, then append
386
- const cacheData = this.getCombinedData(indexTableName);
387
- sheet.writeRowsAt(cacheData.length, rows);
388
- for (const row of rows)
389
- cacheData.push(row);
390
- this.indexRowCount.set(indexTableName, cacheData.length);
391
- }
392
- }
393
- this.searchIndexCache.clear(); // Invalidate n-gram search caches
394
- }
395
- else {
396
- // No-cache path (B3): use known row count to avoid appendRows
397
- const sheet = this.getIndexSheet(indexTableName);
398
- if (!sheet)
399
- return;
400
- const knownCount = this.indexRowCount.get(indexTableName);
401
- if (knownCount !== undefined) {
402
- sheet.writeRowsAt(knownCount, rows);
403
- this.indexRowCount.set(indexTableName, knownCount + rows.length);
404
- }
405
- else {
406
- // Fallback: position unknown, let adapter append
407
- sheet.appendRows(rows);
408
- }
409
- this.searchIndexCache.clear(); // Invalidate n-gram search caches
410
- }
411
- }
412
- }
413
- /**
414
- * Remove **all** index entries for a single entity from the combined sheet.
415
- *
416
- * Reads the sheet data once, filters out rows belonging to `entityId`,
417
- * and rewrites the entire sheet with `replaceAllData()`. This is far
418
- * cheaper than N individual `deleteRow()` calls (each ~300 ms in GAS
419
- * plus O(n) row-shifting).
420
- *
421
- * @param indexTableName - Sheet name.
422
- * @param entityId - Entity UUID whose entries should be removed.
423
- */
424
- removeAllFromCombined(indexTableName, entityId) {
425
- const sheet = this.getIndexSheet(indexTableName);
426
- if (!sheet)
427
- return;
428
- const data = this.getCombinedData(indexTableName);
429
- const remaining = data.filter((row) => String(row[2]) !== entityId);
430
- if (remaining.length === data.length)
431
- return; // Nothing to remove
432
- // Bulk rewrite: single replaceAllData() replaces N deleteRow() calls
433
- sheet.replaceAllData(remaining);
434
- this.indexRowCount.set(indexTableName, remaining.length);
435
- if (this.cache) {
436
- this.cache.set(`cidx:${indexTableName}`, remaining);
437
- this.searchIndexCache.clear();
438
- }
439
- else {
440
- this.clearCache();
441
- }
442
- }
443
- /**
444
- * Remove index entries for **multiple** entities in a single bulk operation.
445
- *
446
- * Reads sheet data once, filters out all rows whose entityId is in the
447
- * provided set, and rewrites with `replaceAllData()`. Use this instead
448
- * of calling `removeAllFromCombined()` in a loop — it avoids N redundant
449
- * sheet reads and writes.
450
- *
451
- * @param indexTableName - Sheet name.
452
- * @param entityIds - Array of entity UUIDs to remove.
453
- */
454
- removeMultipleFromCombined(indexTableName, entityIds) {
455
- if (entityIds.length === 0)
456
- return;
457
- const sheet = this.getIndexSheet(indexTableName);
458
- if (!sheet)
459
- return;
460
- // Convert to Set for O(1) membership checks during filtering
461
- const idSet = new Set(entityIds);
462
- const data = this.getCombinedData(indexTableName);
463
- const remaining = data.filter((row) => !idSet.has(String(row[2])));
464
- if (remaining.length === data.length)
465
- return; // No matching rows found
466
- sheet.replaceAllData(remaining);
467
- this.indexRowCount.set(indexTableName, remaining.length);
468
- if (this.cache) {
469
- this.cache.set(`cidx:${indexTableName}`, remaining);
470
- this.searchIndexCache.clear();
471
- }
472
- else {
473
- this.clearCache();
474
- }
475
- }
476
- /**
477
- * Update index entries for an entity after its field values have changed.
478
- *
479
- * Reads the sheet data **once**, diffs old vs new values for each indexed
480
- * field, then applies the minimal set of writes. Two paths exist:
481
- *
482
- * 1. **In-place update** (fast): when no field is being cleared (old→null),
483
- * each changed row is overwritten at its current position with a single
484
- * `writeRowsAt()`. New fields that previously had no value are appended.
485
- * 2. **Fallback delete+insert**: when a field is cleared, the affected rows
486
- * are removed with `replaceAllData()` and new rows appended.
487
- *
488
- * Unique constraint checks run on the snapshot **before** any writes to
489
- * prevent inconsistent state.
490
- *
491
- * @param indexTableName - Sheet name.
492
- * @param entityId - Entity UUID.
493
- * @param oldValues - Previous field values (before the update).
494
- * @param newValues - New field values (after the update).
495
- * @throws Error on unique index violation.
496
- */
497
- updateInCombined(indexTableName, entityId, oldValues, newValues) {
498
- const sheet = this.getIndexSheet(indexTableName);
499
- if (!sheet)
500
- return;
501
- const indexedFields = this.getIndexedFields(indexTableName);
502
- const changes = [];
503
- for (const meta of indexedFields) {
504
- const field = meta.field;
505
- const oldVal = oldValues[field];
506
- const newVal = newValues[field];
507
- if (oldVal === newVal)
508
- continue; // No change for this field
509
- // Convert to string form (null/undefined/empty → null = "field has no value")
510
- const oldStr = oldVal !== undefined && oldVal !== null && oldVal !== "" ? String(oldVal) : null;
511
- const newStr = newVal !== undefined && newVal !== null && newVal !== "" ? String(newVal) : null;
512
- changes.push({ field, oldStr, newStr, unique: meta.unique });
513
- }
514
- if (changes.length === 0)
515
- return; // No indexed fields changed
516
- // Read sheet data ONCE (may hit getCombinedData cache)
517
- const data = this.getCombinedData(indexTableName);
518
- // ── Uniqueness pre-check: validate all new values before any writes ──
519
- for (const { field, newStr, unique } of changes) {
520
- if (newStr !== null && unique) {
521
- for (let i = 0; i < data.length; i++) {
522
- if (String(data[i][0]) === field &&
523
- String(data[i][1]) === newStr &&
524
- String(data[i][2]) !== entityId) {
525
- throw new Error(`Unique index violation: ${indexTableName}.${field} already has value "${newStr}" for entity ${String(data[i][2])}`);
526
- }
527
- }
528
- }
529
- }
530
- // ── Path 1: In-place update (no field is being cleared) ─────────────────
531
- // When every change has a non-null newStr, we can overwrite each index row
532
- // at its existing position. This avoids replaceAllData()/deleteRow() which
533
- // are expensive in GAS (~300 ms per deleteRow + O(n) row-shifting).
534
- const hasPureDeletion = changes.some((c) => c.oldStr !== null && c.newStr === null);
535
- if (!hasPureDeletion) {
536
- // Build field → old/new string maps for single-pass lookup
537
- const fieldOldMapOpt = new Map();
538
- const fieldNewMapOpt = new Map();
539
- for (const { field, oldStr, newStr } of changes) {
540
- if (oldStr !== null)
541
- fieldOldMapOpt.set(field, oldStr);
542
- if (newStr !== null)
543
- fieldNewMapOpt.set(field, newStr);
544
- }
545
- // Single pass over data: find rows matching (field, oldValue, entityId) and overwrite
546
- for (let i = 0; i < data.length; i++) {
547
- const fc = String(data[i][0]);
548
- const oldStr = fieldOldMapOpt.get(fc);
549
- if (oldStr === undefined || String(data[i][1]) !== oldStr || String(data[i][2]) !== entityId)
550
- continue;
551
- const newStr = fieldNewMapOpt.get(fc);
552
- if (newStr !== undefined) {
553
- const newRow = [fc, newStr, entityId];
554
- sheet.writeRowsAt(i, [newRow]); // Overwrite in-place
555
- data[i] = newRow; // Keep cache consistent
556
- }
557
- }
558
- // Handle pure insertions: field was empty/null → now has a value (no existing row to overwrite)
559
- const insertRows = changes
560
- .filter((c) => c.oldStr === null && c.newStr !== null)
561
- .map((c) => [c.field, c.newStr, entityId]);
562
- if (insertRows.length > 0) {
563
- if (this.cache) {
564
- sheet.writeRowsAt(data.length, insertRows);
565
- for (const r of insertRows)
566
- data.push(r);
567
- this.indexRowCount.set(indexTableName, data.length);
568
- }
569
- else {
570
- for (const r of insertRows)
571
- sheet.appendRow(r);
572
- const prev = this.indexRowCount.get(indexTableName);
573
- if (prev !== undefined)
574
- this.indexRowCount.set(indexTableName, prev + insertRows.length);
575
- }
576
- }
577
- if (!this.cache)
578
- this.clearCache();
579
- else
580
- this.searchIndexCache.clear();
581
- return;
582
- }
583
- // ── Fallback path: handles clearing a field to empty/null (pure deletion) ─
584
- const fieldOldMap = new Map();
585
- for (const { field, oldStr } of changes) {
586
- if (oldStr !== null)
587
- fieldOldMap.set(field, oldStr);
588
- }
589
- const rowsToDelete = [];
590
- for (let i = 0; i < data.length; i++) {
591
- const fc = String(data[i][0]);
592
- const oldStr = fieldOldMap.get(fc);
593
- if (oldStr !== undefined && String(data[i][1]) === oldStr && String(data[i][2]) === entityId) {
594
- rowsToDelete.push(i);
595
- }
596
- }
597
- // ── Path 2: Fallback delete+insert (a field is being cleared) ─────────
598
- // When a field value is set to empty/null, the corresponding index row
599
- // must be removed. We filter out stale rows, rewrite the sheet with
600
- // replaceAllData(), then append new entries.
601
- // Single replaceAllData instead of N individual deleteRow() calls —
602
- // avoids the O(n) row-shift cost per call in GAS.
603
- const deleteSet = new Set(rowsToDelete);
604
- const filteredData = data.filter((_, i) => !deleteSet.has(i));
605
- sheet.replaceAllData(filteredData);
606
- // Update cached array reference in-place so subsequent writes land at correct offset
607
- data.length = 0;
608
- for (const row of filteredData)
609
- data.push(row);
610
- this.indexRowCount.set(indexTableName, data.length);
611
- // Append new index entries for changed fields in a single batch
612
- const newRows = [];
613
- for (const { field, newStr } of changes) {
614
- if (newStr !== null) {
615
- newRows.push([field, newStr, entityId]);
616
- }
617
- }
618
- if (newRows.length > 0) {
619
- if (this.cache) {
620
- sheet.writeRowsAt(data.length, newRows);
621
- for (const row of newRows)
622
- data.push(row);
623
- this.indexRowCount.set(indexTableName, data.length);
624
- this.searchIndexCache.clear();
625
- }
626
- else {
627
- for (const row of newRows)
628
- sheet.appendRow(row);
629
- const prev = this.indexRowCount.get(indexTableName);
630
- if (prev !== undefined)
631
- this.indexRowCount.set(indexTableName, prev + newRows.length);
632
- this.clearCache();
633
- }
634
- }
635
- else if (!this.cache) {
636
- this.clearCache();
637
- }
638
- else {
639
- this.searchIndexCache.clear();
640
- }
641
- }
642
- /**
643
- * Look up entity IDs that match a specific (field, value) pair in the index.
644
- *
645
- * Performs a linear scan of the combined data. Deduplicates results
646
- * using a Set to handle potential index corruption.
647
- *
648
- * @param indexTableName - Sheet name.
649
- * @param field - Indexed field name.
650
- * @param value - Value to look up.
651
- * @returns Array of matching entity UUIDs (deduplicated).
652
- */
653
- lookupCombined(indexTableName, field, value) {
654
- const data = this.getCombinedData(indexTableName);
655
- const valueStr = String(value);
656
- const seen = new Set();
657
- const ids = [];
658
- for (let i = 0; i < data.length; i++) {
659
- if (String(data[i][0]) === field && String(data[i][1]) === valueStr) {
660
- const id = String(data[i][2]);
661
- if (!seen.has(id)) {
662
- seen.add(id);
663
- ids.push(id);
664
- }
665
- }
666
- }
667
- return ids;
668
- }
669
- /**
670
- * Delete an entire combined index sheet and unregister all its fields.
671
- *
672
- * The sheet is physically deleted via the adapter, all caches are cleared,
673
- * and the field registry entries are removed. Cache must be cleared
674
- * **before** removing registry entries because `clearCache()` iterates
675
- * the registry to find cache keys to invalidate.
676
- *
677
- * @param indexTableName - Sheet name to drop.
678
- */
679
- dropCombinedIndex(indexTableName) {
680
- this.adapter.deleteSheet(indexTableName);
681
- this.indexSheetCache.delete(indexTableName);
682
- this.indexRowCount.delete(indexTableName);
683
- // Clear cache BEFORE removing registry entries — clearCache() reads indexRegistry
684
- this.clearCache();
685
- for (const [key, meta] of this.indexRegistry.entries()) {
686
- if (meta.tableName === indexTableName) {
687
- this.indexRegistry.delete(key);
688
- }
689
- }
690
- }
691
- // ─── N-gram search (Solr-like approach) ──────────────────────────────────
692
- // Text search uses a Solr-inspired algorithm originally developed for the
693
- // TyreSizeCatalog project. The approach:
694
- // 1. Normalise text → lowercase, collapse separators/whitespace.
695
- // 2. Tokenise → split on spaces.
696
- // 3. Build trigram posting lists for each indexed value.
697
- // 4. At query time, intersect posting lists for query trigrams.
698
- // 5. Verify surviving candidates with a substring match.
699
- /**
700
- * Normalise a string for search indexing and querying.
701
- *
702
- * Steps: lowercase → trim → convert dashes/underscores/em-dashes to spaces
703
- * → remove commas → collapse whitespace.
704
- *
705
- * @param s - Raw string to normalise.
706
- * @returns Normalised lowercase string.
707
- */
708
- static normalizeForSearch(s) {
709
- if (!s)
710
- return "";
711
- let t = s.toLowerCase().trim();
712
- // Convert dashes, em-dashes, underscores → space for uniform tokenisation
713
- t = t.replace(/[\u2010-\u2015\-\u2013\u2014_]/g, " ");
714
- // Remove commas
715
- t = t.replace(/,/g, " ");
716
- // Collapse whitespace
717
- t = t.replace(/\s+/g, " ").trim();
718
- return t;
719
- }
720
- /**
721
- * Tokenise a normalised string into individual search tokens.
722
- *
723
- * @param normalized - Already-normalised string (via {@link normalizeForSearch}).
724
- * @returns Array of non-empty tokens split on spaces.
725
- */
726
- static tokenize(normalized) {
727
- if (!normalized)
728
- return [];
729
- return normalized.split(" ").filter((t) => t.length > 0);
730
- }
731
- /**
732
- * Generate character-level n-grams from a string.
733
- *
734
- * Whitespace is stripped before generating grams so that token boundaries
735
- * do not create artificial gaps. For the default `NGRAM_SIZE = 3`, the
736
- * string "toyota" produces {"toy", "oyo", "yot", "ota"}.
737
- *
738
- * @param s - Input string.
739
- * @param n - N-gram length (typically 3 = trigram).
740
- * @returns Set of unique n-gram substrings.
741
- */
742
- static ngrams(s, n) {
743
- const out = new Set();
744
- if (!s)
745
- return out;
746
- const t = s.replace(/\s+/g, ""); // Strip whitespace for contiguous grams
747
- if (t.length < n)
748
- return out;
749
- for (let i = 0; i <= t.length - n; i++) {
750
- out.add(t.substring(i, i + n));
751
- }
752
- return out;
753
- }
754
- /**
755
- * Build an in-memory {@link SearchIndex} for a (tableName, field) pair.
756
- *
757
- * Scans all index rows for `field`, normalises each value, and builds:
758
- * - **Token postings**: exact-token → list of entry indices.
759
- * - **Trigram postings**: character-trigram → list of entry indices.
760
- * Trigrams are generated from both individual tokens and the
761
- * compacted (whitespace-free) normalised form.
762
- *
763
- * The result is cached in `searchIndexCache` and reused until the
764
- * index table is modified.
765
- *
766
- * @param indexTableName - Sheet name.
767
- * @param field - Indexed field to build the search index for.
768
- * @returns Fully populated SearchIndex.
769
- */
770
- buildSearchIndex(indexTableName, field) {
771
- const data = this.getCombinedData(indexTableName);
772
- const entries = [];
773
- const normalized = [];
774
- const tokenIndex = new Map(); // token → posting list
775
- const ngramIndex = new Map(); // trigram → posting list
776
- {
777
- // Iterate over every row in the raw index data looking for rows belonging
778
- // to `field`. Each matching row contributes one entry to the search index.
779
- for (let i = 0; i < data.length; i++) {
780
- if (String(data[i][0]) !== field)
781
- continue; // Skip rows for other fields
782
- const value = String(data[i][1]);
783
- const entityId = String(data[i][2]);
784
- const idx = entries.length; // Ordinal position within entries array
785
- entries.push({ value, entityId });
786
- const norm = IndexStore.normalizeForSearch(value);
787
- normalized.push(norm);
788
- // --- Token postings ---
789
- // Split the normalised text into tokens and record which entry index
790
- // each token appears in. This allows O(1) exact-token lookup later.
791
- const tokens = IndexStore.tokenize(norm);
792
- for (const tk of tokens) {
793
- let postings = tokenIndex.get(tk);
794
- if (!postings) {
795
- postings = [];
796
- tokenIndex.set(tk, postings);
797
- }
798
- postings.push(idx);
799
- }
800
- // --- Trigram postings ---
801
- // Collect trigrams from two sources:
802
- // 1. Individual tokens — captures within-word substrings.
803
- // 2. Compacted (no-space) normalised form — captures cross-word
804
- // substrings for multi-word fuzzy matching.
805
- const ngs = new Set();
806
- for (const tk of tokens) {
807
- for (const ng of IndexStore.ngrams(tk, IndexStore.NGRAM_SIZE))
808
- ngs.add(ng);
809
- }
810
- for (const ng of IndexStore.ngrams(norm.replace(/ /g, ""), IndexStore.NGRAM_SIZE))
811
- ngs.add(ng);
812
- for (const ng of ngs) {
813
- let postings = ngramIndex.get(ng);
814
- if (!postings) {
815
- postings = [];
816
- ngramIndex.set(ng, postings);
817
- }
818
- postings.push(idx);
819
- }
820
- }
821
- }
822
- return { entries, normalized, tokenIndex, ngramIndex };
823
- }
824
- /**
825
- * Approximate a token's posting list using trigram intersection.
826
- *
827
- * When a query token does not appear verbatim in the `tokenIndex`, we
828
- * fall back to n-gram matching: decompose the token into trigrams, look
829
- * up each trigram's posting list, and intersect them all. This yields
830
- * candidate entries that share every trigram with the query token —
831
- * a superset of true matches that is refined later by substring
832
- * verification in {@link searchCombined}.
833
- *
834
- * @param token - The query token to approximate.
835
- * @param ngramIndex - Trigram → posting-list map from the search index.
836
- * @returns Intersection of all trigram posting lists / empty if a trigram is missing.
837
- */
838
- static postingsForTokenViaNgrams(token, ngramIndex) {
839
- const ngs = IndexStore.ngrams(token, IndexStore.NGRAM_SIZE);
840
- if (ngs.size === 0)
841
- return []; // Token too short for any trigram
842
- const lists = [];
843
- for (const ng of ngs) {
844
- const p = ngramIndex.get(ng);
845
- if (!p)
846
- return []; // A missing trigram means no entry can match
847
- lists.push(p);
848
- }
849
- // Sort shortest-first so that each intersection step works on the
850
- // smallest possible set, improving performance.
851
- lists.sort((a, b) => a.length - b.length);
852
- return IndexStore.intersectPostingLists(lists);
853
- }
854
- /**
855
- * Intersect N sorted posting lists into a single sorted result.
856
- *
857
- * Reduces pairwise from the first list onward. Callers typically
858
- * pre-sort the input lists by ascending length so the intermediate
859
- * result stays small.
860
- *
861
- * @param lists - Array of sorted posting lists.
862
- * @returns Sorted array of indices present in every input list.
863
- */
864
- static intersectPostingLists(lists) {
865
- if (lists.length === 0)
866
- return [];
867
- let result = lists[0];
868
- for (let i = 1; i < lists.length; i++) {
869
- result = IndexStore.intersectTwo(result, lists[i]);
870
- if (result.length === 0)
871
- break; // Short-circuit: no common elements remain
872
- }
873
- return result;
874
- }
875
- /**
876
- * Two-pointer intersection of two sorted integer arrays.
877
- *
878
- * Both `a` and `b` must be sorted in ascending order. The output
879
- * contains only values present in both arrays, preserving order.
880
- *
881
- * @param a - First sorted posting list.
882
- * @param b - Second sorted posting list.
883
- * @returns Sorted intersection.
884
- */
885
- static intersectTwo(a, b) {
886
- const out = [];
887
- let i = 0;
888
- let j = 0;
889
- while (i < a.length && j < b.length) {
890
- if (a[i] === b[j]) {
891
- out.push(a[i]); // Common element found
892
- i++;
893
- j++;
894
- }
895
- else if (a[i] < b[j]) {
896
- i++; // Advance the smaller pointer
897
- }
898
- else {
899
- j++; // Advance the smaller pointer
900
- }
901
- }
902
- return out;
903
- }
904
- /**
905
- * Search for entity IDs in a combined index by field using n-gram text search.
906
- *
907
- * Implements a Solr-like search algorithm (ported from TyreSizeCatalog):
908
- *
909
- * 1. **Normalise & tokenise** the query string.
910
- * 2. **Token lookup**: for each query token, look up the exact posting
911
- * list. If not found, fall back to trigram-based approximation
912
- * via {@link postingsForTokenViaNgrams}. Tokens shorter than
913
- * `NGRAM_SIZE` skip trigram lookup and include all candidates.
914
- * 3. **Intersect** posting lists across all query tokens — only entries
915
- * that match *every* token survive.
916
- * 4. **Verify** each candidate by checking whether the normalised
917
- * query string is a substring of the normalised index value.
918
- * This eliminates trigram false-positives.
919
- * 5. **Deduplicate** results by entity ID (multiple index rows can
920
- * reference the same entity via different field values).
921
- *
922
- * @param indexTableName - Combined index sheet name.
923
- * @param field - Indexed field to search within.
924
- * @param query - Free-text search query.
925
- * @param limit - Optional maximum number of results.
926
- * @returns Array of matching entity IDs, up to `limit`.
927
- */
928
- searchCombined(indexTableName, field, query, limit) {
929
- if (!query)
930
- return [];
931
- // Retrieve or build the in-memory search index for this (table, field) pair
932
- const cacheKey = `${indexTableName}::${field}`;
933
- let idx = this.searchIndexCache.get(cacheKey);
934
- if (!idx) {
935
- idx = this.buildSearchIndex(indexTableName, field);
936
- this.searchIndexCache.set(cacheKey, idx);
937
- }
938
- if (idx.entries.length === 0)
939
- return []; // No indexed data for this field
940
- // Normalise the query for case/diacritic-insensitive matching
941
- const pat = IndexStore.normalizeForSearch(query);
942
- if (!pat)
943
- return [];
944
- const qTokens = IndexStore.tokenize(pat);
945
- let candidates;
946
- if (qTokens.length === 0) {
947
- // Empty token list (e.g. query was only whitespace) → all entries are candidates
948
- candidates = Array.from({ length: idx.entries.length }, (_, i) => i);
949
- }
950
- else {
951
- // Gather posting lists for each query token
952
- const postings = [];
953
- for (const t of qTokens) {
954
- const p = idx.tokenIndex.get(t);
955
- if (p) {
956
- // Exact token match found in index
957
- postings.push(p);
958
- }
959
- else if (t.length < IndexStore.NGRAM_SIZE) {
960
- // Token is shorter than trigram size — cannot use n-gram approximation.
961
- // Include all candidates; substring verification will filter later.
962
- postings.push(Array.from({ length: idx.entries.length }, (_, i) => i));
963
- }
964
- else {
965
- // Approximate via trigram intersection
966
- const p2 = IndexStore.postingsForTokenViaNgrams(t, idx.ngramIndex);
967
- if (p2.length === 0)
968
- return []; // No trigram match → query cannot match any entry
969
- postings.push(p2);
970
- }
971
- }
972
- // Sort shortest-first for efficient intersection
973
- postings.sort((a, b) => a.length - b.length);
974
- candidates = IndexStore.intersectPostingLists(postings);
975
- if (candidates.length === 0)
976
- return [];
977
- }
978
- // --- Substring verification pass ---
979
- // Trigram intersection is approximate; verify each candidate by checking
980
- // that the full normalised query appears as a substring of the normalised
981
- // index value. Also deduplicate by entity ID.
982
- const maxResults = limit !== undefined && Number.isFinite(limit) && limit >= 0 ? Math.floor(limit) : candidates.length;
983
- if (maxResults === 0)
984
- return [];
985
- const seen = new Set(); // Track seen entity IDs for deduplication
986
- const out = [];
987
- for (const pos of candidates) {
988
- if (idx.normalized[pos].includes(pat)) {
989
- const entityId = idx.entries[pos].entityId;
990
- if (!seen.has(entityId)) {
991
- seen.add(entityId);
992
- out.push(entityId);
993
- }
994
- if (out.length >= maxResults)
995
- break; // Early exit once limit is reached
996
- }
997
- }
998
- return out;
999
- }
1000
- /**
1001
- * Retrieve the raw rows of a combined index sheet, with transparent caching.
1002
- *
1003
- * Avoids redundant `getAllData()` GAS API calls when multiple operations
1004
- * (lookup, search, update) access the same index sheet within a single
1005
- * save cycle. The cache is invalidated via {@link clearCache} after any
1006
- * write operation.
1007
- *
1008
- * When `indexRowCount` records 0 rows for the table (e.g. immediately after
1009
- * {@link createCombinedIndex}), the method skips the `getAllData()` call
1010
- * entirely and returns an empty array.
1011
- *
1012
- * @param indexTableName - Combined index sheet name.
1013
- * @returns 2-D array of raw row data (`[field, value, entityId]` per row).
1014
- */
1015
- getCombinedData(indexTableName) {
1016
- if (this.cache) {
1017
- // --- Cached path ---
1018
- const cacheKey = `cidx:${indexTableName}`;
1019
- const cached = this.cache.get(cacheKey);
1020
- if (cached !== null) {
1021
- // Cache hit — return without touching GAS API
1022
- SheetOrmLogger.log(`[Index:${indexTableName}] getCombinedData cache HIT — ${cached.length} rows`);
1023
- return cached;
1024
- }
1025
- // Sheet is known-empty (just created via createCombinedIndex) — seed cache directly,
1026
- // skip the getAllData() API call that would wastefully read 0 rows from GAS.
1027
- if (this.indexRowCount.get(indexTableName) === 0) {
1028
- const empty = [];
1029
- this.cache.set(cacheKey, empty);
1030
- SheetOrmLogger.log(`[Index:${indexTableName}] getCombinedData cache SEED (empty, skip getAllData)`);
1031
- return empty;
1032
- }
1033
- // Cache miss — fetch from GAS and populate cache
1034
- const sheet = this.getIndexSheet(indexTableName);
1035
- const data = sheet ? sheet.getAllData() : [];
1036
- SheetOrmLogger.log(`[Index:${indexTableName}] getCombinedData cache MISS — read ${data.length} rows from sheet`);
1037
- this.cache.set(cacheKey, data);
1038
- this.indexRowCount.set(indexTableName, data.length); // Keep row count in sync
1039
- return data;
1040
- }
1041
- // --- No-cache path ---
1042
- // Skip getAllData() when table is known-empty
1043
- if (this.indexRowCount.get(indexTableName) === 0) {
1044
- SheetOrmLogger.log(`[Index:${indexTableName}] getCombinedData (no cache) — empty (skip getAllData)`);
1045
- return [];
1046
- }
1047
- const sheet = this.getIndexSheet(indexTableName);
1048
- const data = sheet ? sheet.getAllData() : [];
1049
- SheetOrmLogger.log(`[Index:${indexTableName}] getCombinedData (no cache) — read ${data.length} rows from sheet`);
1050
- this.indexRowCount.set(indexTableName, data.length); // Update tracked row count
1051
- return data;
1052
- }
1053
- /**
1054
- * Invalidate search-index cache entries for a specific index table.
1055
- *
1056
- * Iterates over all keys in `searchIndexCache` and removes any whose
1057
- * prefix matches `indexTableName::`. Called after write operations so
1058
- * that subsequent searches rebuild their in-memory posting lists.
1059
- *
1060
- * @param indexTableName - Sheet name whose search cache should be cleared.
1061
- */
1062
- invalidateSearchCacheForTable(indexTableName) {
1063
- const prefix = `${indexTableName}::`;
1064
- for (const key of this.searchIndexCache.keys()) {
1065
- if (key.startsWith(prefix))
1066
- this.searchIndexCache.delete(key);
1067
- }
1068
- }
1069
- /**
1070
- * Clear all internal index caches — both in-memory search indexes and
1071
- * the data-level cache entries (prefixed `cidx:`).
1072
- *
1073
- * Does not clear the entire shared {@link ICacheProvider}; instead it
1074
- * selectively deletes only index-related keys to preserve other cached
1075
- * data (e.g. entity caches).
1076
- */
1077
- clearCache() {
1078
- this.searchIndexCache.clear(); // Drop all in-memory search indexes
1079
- if (!this.cache)
1080
- return;
1081
- // Only invalidate index-specific keys rather than clearing entire cache
1082
- const cleared = new Set();
1083
- for (const key of this.indexRegistry.keys()) {
1084
- const tableName = key.split("::")[0];
1085
- if (!cleared.has(tableName)) {
1086
- cleared.add(tableName);
1087
- this.cache.delete(`cidx:${tableName}`); // Remove cached raw row data
1088
- }
1089
- }
1090
- }
1091
- /**
1092
- * Public entry point for clearing all index caches (search + data).
1093
- * Called by {@link Registry.clearCache} to ensure full cache coherence
1094
- * when the Registry's data is invalidated.
1095
- */
1096
- clearAllCaches() {
1097
- this.clearCache();
1098
- }
1099
- }
1100
- /** Character-level n-gram length used for search indexing (trigram = 3). */
1101
- IndexStore.NGRAM_SIZE = 3;
1102
- //# sourceMappingURL=IndexStore.js.map