@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.
- package/dist/npm/SheetOrm.d.ts +41 -0
- package/dist/npm/SheetOrm.d.ts.map +1 -0
- package/dist/npm/SheetOrm.js +41 -0
- package/dist/npm/SheetOrm.js.map +1 -0
- package/dist/npm/core/Decorators.d.ts +145 -0
- package/dist/npm/core/Decorators.d.ts.map +1 -0
- package/dist/npm/core/Decorators.js +226 -0
- package/dist/npm/core/Decorators.js.map +1 -0
- package/dist/npm/core/Record.d.ts +167 -0
- package/dist/npm/core/Record.d.ts.map +1 -0
- package/dist/npm/core/Record.js +298 -0
- package/dist/npm/core/Record.js.map +1 -0
- package/dist/npm/core/RecordConstructor.d.ts +20 -0
- package/dist/npm/core/RecordConstructor.d.ts.map +1 -0
- package/dist/npm/core/RecordConstructor.js +2 -0
- package/dist/npm/core/RecordConstructor.js.map +1 -0
- package/dist/npm/core/RecordStatic.d.ts +19 -0
- package/dist/npm/core/RecordStatic.d.ts.map +1 -0
- package/dist/npm/core/RecordStatic.js +2 -0
- package/dist/npm/core/RecordStatic.js.map +1 -0
- package/dist/npm/core/Registry.d.ts +100 -0
- package/dist/npm/core/Registry.d.ts.map +1 -0
- package/dist/npm/core/Registry.js +206 -0
- package/dist/npm/core/Registry.js.map +1 -0
- package/dist/npm/core/SheetRepository.d.ts +331 -0
- package/dist/npm/core/SheetRepository.d.ts.map +1 -0
- package/dist/npm/core/SheetRepository.js +1041 -0
- package/dist/npm/core/SheetRepository.js.map +1 -0
- package/dist/npm/core/cache/MemoryCache.d.ts +67 -0
- package/dist/npm/core/cache/MemoryCache.d.ts.map +1 -0
- package/dist/npm/core/cache/MemoryCache.js +112 -0
- package/dist/npm/core/cache/MemoryCache.js.map +1 -0
- package/dist/npm/core/types/Entity.d.ts +21 -0
- package/dist/npm/core/types/Entity.d.ts.map +1 -0
- package/dist/npm/core/types/Entity.js +2 -0
- package/dist/npm/core/types/Entity.js.map +1 -0
- package/dist/npm/core/types/FieldDefinition.d.ts +21 -0
- package/dist/npm/core/types/FieldDefinition.d.ts.map +1 -0
- package/dist/npm/core/types/FieldDefinition.js +2 -0
- package/dist/npm/core/types/FieldDefinition.js.map +1 -0
- package/dist/npm/core/types/FieldType.d.ts +12 -0
- package/dist/npm/core/types/FieldType.d.ts.map +1 -0
- package/dist/npm/core/types/FieldType.js +2 -0
- package/dist/npm/core/types/FieldType.js.map +1 -0
- package/dist/npm/core/types/Filter.d.ts +16 -0
- package/dist/npm/core/types/Filter.d.ts.map +1 -0
- package/dist/npm/core/types/Filter.js +2 -0
- package/dist/npm/core/types/Filter.js.map +1 -0
- package/dist/npm/core/types/FilterOperator.d.ts +16 -0
- package/dist/npm/core/types/FilterOperator.d.ts.map +1 -0
- package/dist/npm/core/types/FilterOperator.js +2 -0
- package/dist/npm/core/types/FilterOperator.js.map +1 -0
- package/dist/npm/core/types/GroupResult.d.ts +14 -0
- package/dist/npm/core/types/GroupResult.d.ts.map +1 -0
- package/dist/npm/core/types/GroupResult.js +2 -0
- package/dist/npm/core/types/GroupResult.js.map +1 -0
- package/dist/npm/core/types/ICacheProvider.d.ts +20 -0
- package/dist/npm/core/types/ICacheProvider.d.ts.map +1 -0
- package/dist/npm/core/types/ICacheProvider.js +2 -0
- package/dist/npm/core/types/ICacheProvider.js.map +1 -0
- package/dist/npm/core/types/ISheetAdapter.d.ts +54 -0
- package/dist/npm/core/types/ISheetAdapter.d.ts.map +1 -0
- package/dist/npm/core/types/ISheetAdapter.js +2 -0
- package/dist/npm/core/types/ISheetAdapter.js.map +1 -0
- package/dist/npm/core/types/ISpreadsheetAdapter.d.ts +34 -0
- package/dist/npm/core/types/ISpreadsheetAdapter.d.ts.map +1 -0
- package/dist/npm/core/types/ISpreadsheetAdapter.js +2 -0
- package/dist/npm/core/types/ISpreadsheetAdapter.js.map +1 -0
- package/dist/npm/core/types/IndexDefinition.d.ts +16 -0
- package/dist/npm/core/types/IndexDefinition.d.ts.map +1 -0
- package/dist/npm/core/types/IndexDefinition.js +2 -0
- package/dist/npm/core/types/IndexDefinition.js.map +1 -0
- package/dist/npm/core/types/LifecycleHooks.d.ts +32 -0
- package/dist/npm/core/types/LifecycleHooks.d.ts.map +1 -0
- package/dist/npm/core/types/LifecycleHooks.js +2 -0
- package/dist/npm/core/types/LifecycleHooks.js.map +1 -0
- package/dist/npm/core/types/PaginatedResult.d.ts +18 -0
- package/dist/npm/core/types/PaginatedResult.d.ts.map +1 -0
- package/dist/npm/core/types/PaginatedResult.js +2 -0
- package/dist/npm/core/types/PaginatedResult.js.map +1 -0
- package/dist/npm/core/types/QueryOptions.d.ts +21 -0
- package/dist/npm/core/types/QueryOptions.d.ts.map +1 -0
- package/dist/npm/core/types/QueryOptions.js +2 -0
- package/dist/npm/core/types/QueryOptions.js.map +1 -0
- package/dist/npm/core/types/SortClause.d.ts +10 -0
- package/dist/npm/core/types/SortClause.d.ts.map +1 -0
- package/dist/npm/core/types/SortClause.js +2 -0
- package/dist/npm/core/types/SortClause.js.map +1 -0
- package/dist/npm/core/types/SystemColumns.d.ts +16 -0
- package/dist/npm/core/types/SystemColumns.d.ts.map +1 -0
- package/dist/npm/core/types/SystemColumns.js +16 -0
- package/dist/npm/core/types/SystemColumns.js.map +1 -0
- package/dist/npm/core/types/TableSchema.d.ts +24 -0
- package/dist/npm/core/types/TableSchema.d.ts.map +1 -0
- package/dist/npm/core/types/TableSchema.js +2 -0
- package/dist/npm/core/types/TableSchema.js.map +1 -0
- package/dist/npm/index/IndexMeta.d.ts +16 -0
- package/dist/npm/index/IndexMeta.d.ts.map +1 -0
- package/dist/npm/index/IndexMeta.js +2 -0
- package/dist/npm/index/IndexMeta.js.map +1 -0
- package/dist/npm/index/IndexStore.d.ts +399 -0
- package/dist/npm/index/IndexStore.d.ts.map +1 -0
- package/dist/npm/index/IndexStore.js +1104 -0
- package/dist/npm/index/IndexStore.js.map +1 -0
- package/dist/npm/index.d.ts +115 -0
- package/dist/npm/index.d.ts.map +1 -0
- package/dist/npm/index.js +367 -0
- package/dist/npm/index.js.map +1 -0
- package/dist/npm/query/Query.d.ts +176 -0
- package/dist/npm/query/Query.d.ts.map +1 -0
- package/dist/npm/query/Query.js +270 -0
- package/dist/npm/query/Query.js.map +1 -0
- package/dist/npm/query/QueryEngine.d.ts +140 -0
- package/dist/npm/query/QueryEngine.d.ts.map +1 -0
- package/dist/npm/query/QueryEngine.js +453 -0
- package/dist/npm/query/QueryEngine.js.map +1 -0
- package/dist/npm/storage/GoogleSheetAdapter.d.ts +120 -0
- package/dist/npm/storage/GoogleSheetAdapter.d.ts.map +1 -0
- package/dist/npm/storage/GoogleSheetAdapter.js +239 -0
- package/dist/npm/storage/GoogleSheetAdapter.js.map +1 -0
- package/dist/npm/storage/GoogleSpreadsheetAdapter.d.ts +55 -0
- package/dist/npm/storage/GoogleSpreadsheetAdapter.d.ts.map +1 -0
- package/dist/npm/storage/GoogleSpreadsheetAdapter.js +99 -0
- package/dist/npm/storage/GoogleSpreadsheetAdapter.js.map +1 -0
- package/dist/npm/testing/ParityCatalog.d.ts +38 -0
- package/dist/npm/testing/ParityCatalog.d.ts.map +1 -0
- package/dist/npm/testing/ParityCatalog.js +400 -0
- package/dist/npm/testing/ParityCatalog.js.map +1 -0
- package/dist/npm/testing/RuntimeBenchmark.d.ts +37 -0
- package/dist/npm/testing/RuntimeBenchmark.d.ts.map +1 -0
- package/dist/npm/testing/RuntimeBenchmark.js +449 -0
- package/dist/npm/testing/RuntimeBenchmark.js.map +1 -0
- package/dist/npm/testing/RuntimeParity.d.ts +62 -0
- package/dist/npm/testing/RuntimeParity.d.ts.map +1 -0
- package/dist/npm/testing/RuntimeParity.js +4030 -0
- package/dist/npm/testing/RuntimeParity.js.map +1 -0
- package/dist/npm/utils/Serialization.d.ts +111 -0
- package/dist/npm/utils/Serialization.d.ts.map +1 -0
- package/dist/npm/utils/Serialization.js +281 -0
- package/dist/npm/utils/Serialization.js.map +1 -0
- package/dist/npm/utils/SheetOrmLogger.d.ts +31 -0
- package/dist/npm/utils/SheetOrmLogger.d.ts.map +1 -0
- package/dist/npm/utils/SheetOrmLogger.js +42 -0
- package/dist/npm/utils/SheetOrmLogger.js.map +1 -0
- package/dist/npm/utils/Uuid.d.ts +29 -0
- package/dist/npm/utils/Uuid.d.ts.map +1 -0
- package/dist/npm/utils/Uuid.js +86 -0
- package/dist/npm/utils/Uuid.js.map +1 -0
- package/license.md +18 -0
- package/package.json +77 -0
- 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
|