@b2bc-devkit/sheetorm 0.1.0

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