@b2bc-devkit/sheetorm 1.1.0 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/package.json +171 -86
  2. package/readme.md +57 -17
  3. package/dist/npm/SheetOrm.d.ts +0 -41
  4. package/dist/npm/SheetOrm.d.ts.map +0 -1
  5. package/dist/npm/SheetOrm.js +0 -41
  6. package/dist/npm/SheetOrm.js.map +0 -1
  7. package/dist/npm/core/Decorators.d.ts +0 -145
  8. package/dist/npm/core/Decorators.d.ts.map +0 -1
  9. package/dist/npm/core/Decorators.js +0 -226
  10. package/dist/npm/core/Decorators.js.map +0 -1
  11. package/dist/npm/core/Record.d.ts +0 -178
  12. package/dist/npm/core/Record.d.ts.map +0 -1
  13. package/dist/npm/core/Record.js +0 -313
  14. package/dist/npm/core/Record.js.map +0 -1
  15. package/dist/npm/core/RecordConstructor.d.ts +0 -20
  16. package/dist/npm/core/RecordConstructor.d.ts.map +0 -1
  17. package/dist/npm/core/RecordConstructor.js +0 -2
  18. package/dist/npm/core/RecordConstructor.js.map +0 -1
  19. package/dist/npm/core/RecordStatic.d.ts +0 -23
  20. package/dist/npm/core/RecordStatic.d.ts.map +0 -1
  21. package/dist/npm/core/RecordStatic.js +0 -2
  22. package/dist/npm/core/RecordStatic.js.map +0 -1
  23. package/dist/npm/core/Registry.d.ts +0 -101
  24. package/dist/npm/core/Registry.d.ts.map +0 -1
  25. package/dist/npm/core/Registry.js +0 -226
  26. package/dist/npm/core/Registry.js.map +0 -1
  27. package/dist/npm/core/SheetRepository.d.ts +0 -331
  28. package/dist/npm/core/SheetRepository.d.ts.map +0 -1
  29. package/dist/npm/core/SheetRepository.js +0 -1041
  30. package/dist/npm/core/SheetRepository.js.map +0 -1
  31. package/dist/npm/core/cache/MemoryCache.d.ts +0 -67
  32. package/dist/npm/core/cache/MemoryCache.d.ts.map +0 -1
  33. package/dist/npm/core/cache/MemoryCache.js +0 -112
  34. package/dist/npm/core/cache/MemoryCache.js.map +0 -1
  35. package/dist/npm/core/types/Entity.d.ts +0 -21
  36. package/dist/npm/core/types/Entity.d.ts.map +0 -1
  37. package/dist/npm/core/types/Entity.js +0 -2
  38. package/dist/npm/core/types/Entity.js.map +0 -1
  39. package/dist/npm/core/types/FieldDefinition.d.ts +0 -21
  40. package/dist/npm/core/types/FieldDefinition.d.ts.map +0 -1
  41. package/dist/npm/core/types/FieldDefinition.js +0 -2
  42. package/dist/npm/core/types/FieldDefinition.js.map +0 -1
  43. package/dist/npm/core/types/FieldType.d.ts +0 -12
  44. package/dist/npm/core/types/FieldType.d.ts.map +0 -1
  45. package/dist/npm/core/types/FieldType.js +0 -2
  46. package/dist/npm/core/types/FieldType.js.map +0 -1
  47. package/dist/npm/core/types/Filter.d.ts +0 -16
  48. package/dist/npm/core/types/Filter.d.ts.map +0 -1
  49. package/dist/npm/core/types/Filter.js +0 -2
  50. package/dist/npm/core/types/Filter.js.map +0 -1
  51. package/dist/npm/core/types/FilterOperator.d.ts +0 -16
  52. package/dist/npm/core/types/FilterOperator.d.ts.map +0 -1
  53. package/dist/npm/core/types/FilterOperator.js +0 -2
  54. package/dist/npm/core/types/FilterOperator.js.map +0 -1
  55. package/dist/npm/core/types/GroupResult.d.ts +0 -14
  56. package/dist/npm/core/types/GroupResult.d.ts.map +0 -1
  57. package/dist/npm/core/types/GroupResult.js +0 -2
  58. package/dist/npm/core/types/GroupResult.js.map +0 -1
  59. package/dist/npm/core/types/ICacheProvider.d.ts +0 -20
  60. package/dist/npm/core/types/ICacheProvider.d.ts.map +0 -1
  61. package/dist/npm/core/types/ICacheProvider.js +0 -2
  62. package/dist/npm/core/types/ICacheProvider.js.map +0 -1
  63. package/dist/npm/core/types/ISheetAdapter.d.ts +0 -53
  64. package/dist/npm/core/types/ISheetAdapter.d.ts.map +0 -1
  65. package/dist/npm/core/types/ISheetAdapter.js +0 -2
  66. package/dist/npm/core/types/ISheetAdapter.js.map +0 -1
  67. package/dist/npm/core/types/ISpreadsheetAdapter.d.ts +0 -41
  68. package/dist/npm/core/types/ISpreadsheetAdapter.d.ts.map +0 -1
  69. package/dist/npm/core/types/ISpreadsheetAdapter.js +0 -2
  70. package/dist/npm/core/types/ISpreadsheetAdapter.js.map +0 -1
  71. package/dist/npm/core/types/IndexDefinition.d.ts +0 -16
  72. package/dist/npm/core/types/IndexDefinition.d.ts.map +0 -1
  73. package/dist/npm/core/types/IndexDefinition.js +0 -2
  74. package/dist/npm/core/types/IndexDefinition.js.map +0 -1
  75. package/dist/npm/core/types/LifecycleHooks.d.ts +0 -32
  76. package/dist/npm/core/types/LifecycleHooks.d.ts.map +0 -1
  77. package/dist/npm/core/types/LifecycleHooks.js +0 -2
  78. package/dist/npm/core/types/LifecycleHooks.js.map +0 -1
  79. package/dist/npm/core/types/PaginatedResult.d.ts +0 -18
  80. package/dist/npm/core/types/PaginatedResult.d.ts.map +0 -1
  81. package/dist/npm/core/types/PaginatedResult.js +0 -2
  82. package/dist/npm/core/types/PaginatedResult.js.map +0 -1
  83. package/dist/npm/core/types/QueryOptions.d.ts +0 -21
  84. package/dist/npm/core/types/QueryOptions.d.ts.map +0 -1
  85. package/dist/npm/core/types/QueryOptions.js +0 -2
  86. package/dist/npm/core/types/QueryOptions.js.map +0 -1
  87. package/dist/npm/core/types/SortClause.d.ts +0 -10
  88. package/dist/npm/core/types/SortClause.d.ts.map +0 -1
  89. package/dist/npm/core/types/SortClause.js +0 -2
  90. package/dist/npm/core/types/SortClause.js.map +0 -1
  91. package/dist/npm/core/types/SystemColumns.d.ts +0 -16
  92. package/dist/npm/core/types/SystemColumns.d.ts.map +0 -1
  93. package/dist/npm/core/types/SystemColumns.js +0 -16
  94. package/dist/npm/core/types/SystemColumns.js.map +0 -1
  95. package/dist/npm/core/types/TableSchema.d.ts +0 -24
  96. package/dist/npm/core/types/TableSchema.d.ts.map +0 -1
  97. package/dist/npm/core/types/TableSchema.js +0 -2
  98. package/dist/npm/core/types/TableSchema.js.map +0 -1
  99. package/dist/npm/index/IndexMeta.d.ts +0 -16
  100. package/dist/npm/index/IndexMeta.d.ts.map +0 -1
  101. package/dist/npm/index/IndexMeta.js +0 -2
  102. package/dist/npm/index/IndexMeta.js.map +0 -1
  103. package/dist/npm/index/IndexStore.d.ts +0 -397
  104. package/dist/npm/index/IndexStore.d.ts.map +0 -1
  105. package/dist/npm/index/IndexStore.js +0 -1102
  106. package/dist/npm/index/IndexStore.js.map +0 -1
  107. package/dist/npm/index.d.ts +0 -115
  108. package/dist/npm/index.d.ts.map +0 -1
  109. package/dist/npm/index.js +0 -367
  110. package/dist/npm/index.js.map +0 -1
  111. package/dist/npm/query/Query.d.ts +0 -176
  112. package/dist/npm/query/Query.d.ts.map +0 -1
  113. package/dist/npm/query/Query.js +0 -270
  114. package/dist/npm/query/Query.js.map +0 -1
  115. package/dist/npm/query/QueryEngine.d.ts +0 -140
  116. package/dist/npm/query/QueryEngine.d.ts.map +0 -1
  117. package/dist/npm/query/QueryEngine.js +0 -453
  118. package/dist/npm/query/QueryEngine.js.map +0 -1
  119. package/dist/npm/storage/GoogleSheetAdapter.d.ts +0 -119
  120. package/dist/npm/storage/GoogleSheetAdapter.d.ts.map +0 -1
  121. package/dist/npm/storage/GoogleSheetAdapter.js +0 -238
  122. package/dist/npm/storage/GoogleSheetAdapter.js.map +0 -1
  123. package/dist/npm/storage/GoogleSpreadsheetAdapter.d.ts +0 -65
  124. package/dist/npm/storage/GoogleSpreadsheetAdapter.d.ts.map +0 -1
  125. package/dist/npm/storage/GoogleSpreadsheetAdapter.js +0 -119
  126. package/dist/npm/storage/GoogleSpreadsheetAdapter.js.map +0 -1
  127. package/dist/npm/testing/ParityCatalog.d.ts +0 -38
  128. package/dist/npm/testing/ParityCatalog.d.ts.map +0 -1
  129. package/dist/npm/testing/ParityCatalog.js +0 -404
  130. package/dist/npm/testing/ParityCatalog.js.map +0 -1
  131. package/dist/npm/testing/RuntimeBenchmark.d.ts +0 -37
  132. package/dist/npm/testing/RuntimeBenchmark.d.ts.map +0 -1
  133. package/dist/npm/testing/RuntimeBenchmark.js +0 -449
  134. package/dist/npm/testing/RuntimeBenchmark.js.map +0 -1
  135. package/dist/npm/testing/RuntimeParity.d.ts +0 -62
  136. package/dist/npm/testing/RuntimeParity.d.ts.map +0 -1
  137. package/dist/npm/testing/RuntimeParity.js +0 -4126
  138. package/dist/npm/testing/RuntimeParity.js.map +0 -1
  139. package/dist/npm/utils/Serialization.d.ts +0 -111
  140. package/dist/npm/utils/Serialization.d.ts.map +0 -1
  141. package/dist/npm/utils/Serialization.js +0 -281
  142. package/dist/npm/utils/Serialization.js.map +0 -1
  143. package/dist/npm/utils/SheetOrmLogger.d.ts +0 -31
  144. package/dist/npm/utils/SheetOrmLogger.d.ts.map +0 -1
  145. package/dist/npm/utils/SheetOrmLogger.js +0 -42
  146. package/dist/npm/utils/SheetOrmLogger.js.map +0 -1
  147. package/dist/npm/utils/Uuid.d.ts +0 -29
  148. package/dist/npm/utils/Uuid.d.ts.map +0 -1
  149. package/dist/npm/utils/Uuid.js +0 -86
  150. package/dist/npm/utils/Uuid.js.map +0 -1
@@ -1,1041 +0,0 @@
1
- /**
2
- * Main CRUD and query interface for a single entity type.
3
- *
4
- * Each `SheetRepository<T>` manages one Google Sheets tab, converting
5
- * between in-memory {@link Entity} objects and sheet rows via
6
- * {@link Serialization}. Inspired by the Repository pattern from
7
- * common ORM architectures (Hibernate, TypeORM, etc.).
8
- *
9
- * Performance optimisations (referenced by codename in comments):
10
- * - **B5** — Known row count passed from Registry avoids getLastRow().
11
- * - **B7** — `sheetCache` memoises the ISheetAdapter for the tab.
12
- * - **K1** — Reuses physicalRowCount across saves inside saveAll().
13
- * - **K2** — Skips index sheet lookup when the class has no @Indexed fields.
14
- * - **L1** — Defers header write to the first data flush (saves ~700 ms).
15
- * - **L2** — Bulk delete rewrite for delete-only batch commits.
16
- *
17
- * @module SheetRepository
18
- */
19
- import { SystemColumns } from "../core/types/SystemColumns.js";
20
- import { Uuid } from "../utils/Uuid.js";
21
- import { Serialization } from "../utils/Serialization.js";
22
- import { Query } from "../query/Query.js";
23
- import { QueryEngine } from "../query/QueryEngine.js";
24
- import { SheetOrmLogger } from "../utils/SheetOrmLogger.js";
25
- /**
26
- * Repository providing CRUD, query, pagination, and batch operations
27
- * for entities of type `T` stored in a single Google Sheet tab.
28
- *
29
- * @typeParam T - Entity type managed by this repository.
30
- */
31
- export class SheetRepository {
32
- /**
33
- * Constructs a new repository for the given table schema.
34
- *
35
- * @param adapter - Spreadsheet adapter providing sheet management.
36
- * @param schema - Table schema (name, fields, indexes).
37
- * @param indexStore - Shared IndexStore for secondary indexes.
38
- * @param cache - Optional cache provider for in-memory row caching.
39
- * @param hooks - Optional lifecycle hooks (onValidate, beforeSave, afterSave, beforeDelete, afterDelete).
40
- * @param initialSheet - Pre-resolved sheet adapter (avoids redundant getSheetByName call).
41
- * @param initialRowCount - Pre-fetched row count (avoids redundant getLastRow call).
42
- * @param headersDeferred - True if the sheet was just created and headers still need writing.
43
- */
44
- constructor(adapter, schema, indexStore, cache, hooks, initialSheet, initialRowCount, headersDeferred) {
45
- /** Fast-lookup map: entity ID → 1-based sheet row index for O(1) row access. */
46
- this.idToRowIndex = null;
47
- /** Buffered operations when a batch is active (beginBatch/commitBatch). */
48
- this.batchBuffer = null;
49
- /**
50
- * Entity batch accumulator used by saveAll().
51
- * Each entry stores the entity, its serialised row, 0-based data index, and
52
- * whether it is a "create" or "update". Flushed via flushEntityBatch().
53
- */
54
- this.entityBatch = null;
55
- /** Cached sheet reference for the duration of saveAll() — avoids 1000× getSheetByName(). */
56
- this.batchSheet = null;
57
- /** Row count captured once at saveAll() start — avoids 1000× getLastRow(). */
58
- this.batchBaseRowCount = null;
59
- /** Cached entity array used by updateCacheAfterSave in batch mode — avoids 1000× cache.get() log calls. */
60
- this.batchCachedData = null;
61
- /** Indexed fields metadata, cached for the duration of saveAll() — avoids 1000× getIndexedFields() array alloc. */
62
- this.batchIndexedFields = null;
63
- /** Physical sheet row count, tracked to avoid repeated getLastRow() API calls in non-batch save sequences. */
64
- this.physicalRowCount = null;
65
- /** True when idToRowIndex was built from a complete sheet scan (bootstrap or loadAllEntities/rowIndexById). */
66
- this.idToRowIndexComplete = false;
67
- /** Memoized sheet reference — avoids repeated getSheetByName() API calls within a session (B7). */
68
- this.sheetCache = null;
69
- this.adapter = adapter;
70
- this.schema = schema;
71
- this.indexStore = indexStore;
72
- this.cache = cache ?? null;
73
- this.hooks = hooks ?? {};
74
- // Seed memoised sheet & row count when provided by Registry
75
- this.sheetCache = initialSheet ?? null;
76
- this.physicalRowCount = initialRowCount ?? null;
77
- // Build column header array from field definitions (used for serialisation)
78
- this.headers = Serialization.buildHeaders(schema.fields);
79
- // Locate the __id column position for fast primary-key lookups
80
- this.idColIdx = this.headers.indexOf(SystemColumns.ID);
81
- // Pre-filter required and defaultable fields for validation/defaults in save()
82
- this.requiredFields = schema.fields.filter((f) => f.required);
83
- this.defaultableFields = schema.fields.filter((f) => f.defaultValue !== undefined);
84
- // Cache key for the data cache (all-entity array)
85
- this.dataCacheKey = `data:${schema.tableName}`;
86
- // Pre-build field lookup map once (reused by entityToRow / rowToEntity)
87
- this.fieldMap = new Map();
88
- for (const f of schema.fields) {
89
- this.fieldMap.set(f.name, f);
90
- }
91
- // Pre-build indexed-field name set once — avoids getIndexedFields() array alloc on every find()
92
- this.indexedFieldNames = new Set(schema.indexes.map((idx) => idx.field));
93
- // Defer header write to first data flush — saves one GAS API call per new sheet
94
- this.headersDeferred = headersDeferred ?? false;
95
- SheetOrmLogger.log(`[Repo:${schema.tableName}] constructor — ` +
96
- `sheetCache=${initialSheet ? "seeded" : "null"} ` +
97
- `physicalRowCount=${initialRowCount !== undefined ? String(initialRowCount) : "unknown"} ` +
98
- `indexedFields=[${schema.indexes.map((i) => i.field).join(",")}]`);
99
- }
100
- // ─── CRUD ──────────────────────────────────────────
101
- /**
102
- * Save (create or update) an entity.
103
- *
104
- * When a batch is active (see {@link beginBatch}), the operation is buffered
105
- * and deferred until {@link commitBatch} is called. Otherwise delegates
106
- * immediately to {@link doSave}.
107
- *
108
- * @param partial - Partial entity with optional `__id`.
109
- * @returns The saved entity with system columns populated.
110
- */
111
- save(partial) {
112
- if (this.batchBuffer) {
113
- const now = new Date().toISOString();
114
- const id = partial.__id ?? Uuid.generate();
115
- // Heuristic: __id present *with* __createdAt → likely an existing entity (update).
116
- // __id present *without* __createdAt → caller-supplied ID for a new entity.
117
- const isLikelyUpdate = Boolean(partial.__id && partial.__createdAt);
118
- const buffered = { ...partial, __id: id };
119
- this.batchBuffer.push({ type: "save", data: buffered });
120
- return {
121
- ...buffered,
122
- ...(!isLikelyUpdate ? { __createdAt: now } : {}),
123
- __updatedAt: now,
124
- };
125
- }
126
- return this.doSave(partial);
127
- }
128
- /**
129
- * Internal save implementation — resolves existence, validates, applies
130
- * defaults, and writes to the sheet or buffers into an entity batch.
131
- *
132
- * @param partial - Partial entity data with optional `__id`.
133
- * @returns Fully populated entity with system columns.
134
- */
135
- doSave(partial) {
136
- const sheet = this.batchSheet ?? this.getSheet();
137
- // Per-entity log is suppressed in batch mode to avoid 1000× Logger.log() overhead in GAS
138
- if (!this.entityBatch) {
139
- SheetOrmLogger.log(`[Repo:${this.schema.tableName}] doSave — batchMode=false id=${partial.__id ?? "(new)"}`);
140
- }
141
- const now = new Date().toISOString();
142
- // ── Existence check: prefer in-memory index, fall back to single API call ──
143
- let existingIdx = null;
144
- let existingEntity = null;
145
- if (partial.__id) {
146
- // Try in-memory lookup: idToRowIndex gives us the 0-based data row index,
147
- // and the entity cache gives us the current field values (needed for UPDATE merge).
148
- const cachedIdx = this.idToRowIndex?.get(partial.__id);
149
- if (cachedIdx !== undefined && this.cache) {
150
- const cached = this.cache.get(this.dataCacheKey);
151
- if (cached) {
152
- const cachedEntity = cached[cachedIdx];
153
- // Validate that the cached ID matches — gap rows may cause cache index drift
154
- if (cachedEntity?.__id === partial.__id) {
155
- existingIdx = cachedIdx;
156
- existingEntity = cachedEntity;
157
- }
158
- }
159
- }
160
- // Fallback: full-scan the sheet's ID column, deserialize only the matching row
161
- if (existingIdx === null) {
162
- if (this.idToRowIndex && this.idToRowIndexComplete && !this.idToRowIndex.has(partial.__id)) {
163
- // idToRowIndex covers all rows; entity's ID is absent → definitely new, skip sheet read.
164
- }
165
- else {
166
- const data = sheet.getAllData();
167
- const rowIndex = new Map();
168
- const col = this.idColIdx;
169
- for (let i = 0; i < data.length; i++) {
170
- const rowId = String(data[i][col]);
171
- rowIndex.set(rowId, i);
172
- if (rowId === partial.__id) {
173
- existingIdx = i;
174
- existingEntity = Serialization.rowToEntity(data[i], this.headers, this.schema.fields, this.fieldMap);
175
- }
176
- }
177
- // Rebuild the full index as a side effect of the scan
178
- this.idToRowIndex = rowIndex;
179
- this.physicalRowCount = data.length;
180
- this.idToRowIndexComplete = true;
181
- }
182
- }
183
- }
184
- const isNew = existingIdx === null;
185
- if (!this.entityBatch) {
186
- SheetOrmLogger.log(`[Repo:${this.schema.tableName}] doSave — isNew=${isNew}${existingIdx !== null ? ` rowIdx=${existingIdx}` : ""}`);
187
- }
188
- // ── Lifecycle: validate ──
189
- if (this.hooks.onValidate) {
190
- const errors = this.hooks.onValidate(partial);
191
- if (errors && errors.length > 0) {
192
- throw new Error(`Validation failed: ${errors.join(", ")}`);
193
- }
194
- }
195
- // ── Lifecycle: beforeSave ──
196
- let entityData = partial;
197
- if (this.hooks.beforeSave) {
198
- const result = this.hooks.beforeSave(partial, isNew);
199
- if (result)
200
- entityData = result;
201
- }
202
- // Apply defaults for fields with defaultValue (only when undefined)
203
- for (let i = 0; i < this.defaultableFields.length; i++) {
204
- const field = this.defaultableFields[i];
205
- if (entityData[field.name] === undefined) {
206
- entityData[field.name] = field.defaultValue;
207
- }
208
- }
209
- // Validate required fields (must not be undefined, null, or empty string)
210
- for (let i = 0; i < this.requiredFields.length; i++) {
211
- const field = this.requiredFields[i];
212
- const val = entityData[field.name];
213
- if (val === undefined || val === null || val === "") {
214
- throw new Error(`Required field "${field.name}" is missing for table "${this.schema.tableName}"`);
215
- }
216
- }
217
- let entity;
218
- if (isNew) {
219
- // ── CREATE path ──
220
- entity = {
221
- ...entityData,
222
- __id: entityData.__id ?? Uuid.generate(),
223
- __createdAt: now,
224
- __updatedAt: now,
225
- };
226
- const row = Serialization.entityToRow(entity, this.schema.fields, this.headers, this.fieldMap);
227
- // Compute the 0-based data row index for writing.
228
- // In batch mode (saveAll), batchBaseRowCount was captured once at batch start.
229
- // Otherwise reuse physicalRowCount to avoid getLastRow() API calls.
230
- const baseCount = this.batchBaseRowCount !== null
231
- ? this.batchBaseRowCount
232
- : this.physicalRowCount !== null
233
- ? this.physicalRowCount
234
- : sheet.getRowCount();
235
- // Account for already-buffered CREATE entities that haven't been flushed yet
236
- const dataIndex = baseCount + (this.entityBatch ? this.entityBatch.filter((item) => item.mode === "create").length : 0);
237
- // Bootstrap idToRowIndex and cache when first entity is written to an empty sheet
238
- if (!this.idToRowIndex) {
239
- if (dataIndex === 0) {
240
- this.idToRowIndex = new Map();
241
- this.physicalRowCount = 0;
242
- this.idToRowIndexComplete = true;
243
- // Seed empty cache so subsequent finds are cache hits
244
- if (this.cache && !this.cache.has(this.dataCacheKey)) {
245
- this.cache.set(this.dataCacheKey, []);
246
- }
247
- }
248
- }
249
- // In entity batch mode (saveAll): buffer the row; otherwise write immediately
250
- if (this.entityBatch !== null) {
251
- this.entityBatch.push({ entity, row, dataIndex, mode: "create" });
252
- }
253
- else {
254
- // Write headers + first data row in a single API call for newly-created sheets
255
- if (this.headersDeferred) {
256
- sheet.writeAllRowsWithHeaders(this.headers, [row]);
257
- this.headersDeferred = false;
258
- }
259
- else {
260
- sheet.updateRow(dataIndex, row);
261
- }
262
- // Keep physicalRowCount in sync
263
- if (this.physicalRowCount !== null)
264
- this.physicalRowCount++;
265
- }
266
- // Add to secondary indexes (@Indexed fields)
267
- this.addToIndexes(entity);
268
- // Update in-memory row index (always, even in batch — so the next entity gets the correct dataIndex)
269
- if (this.idToRowIndex) {
270
- this.idToRowIndex.set(entity.__id, dataIndex);
271
- }
272
- }
273
- else {
274
- // ── UPDATE path — merge existing fields with new values ──
275
- entity = {
276
- ...existingEntity,
277
- ...entityData,
278
- __id: existingEntity.__id,
279
- __createdAt: existingEntity.__createdAt,
280
- __updatedAt: now,
281
- };
282
- const row = Serialization.entityToRow(entity, this.schema.fields, this.headers, this.fieldMap);
283
- if (this.entityBatch !== null) {
284
- this.entityBatch.push({ entity, row, dataIndex: existingIdx, mode: "update" });
285
- }
286
- else {
287
- sheet.updateRow(existingIdx, row);
288
- }
289
- // Update secondary indexes with old → new value changes
290
- const oldValues = {};
291
- const newValues = {};
292
- for (const field of this.schema.fields) {
293
- oldValues[field.name] = existingEntity[field.name];
294
- newValues[field.name] = entity[field.name];
295
- }
296
- if (this.schema.indexTableName) {
297
- this.indexStore.updateInCombined(this.schema.indexTableName, entity.__id, oldValues, newValues);
298
- }
299
- }
300
- // Update entity cache in place (avoid full invalidation after every save)
301
- this.updateCacheAfterSave(entity, isNew);
302
- // ── Lifecycle: afterSave ──
303
- if (this.hooks.afterSave) {
304
- this.hooks.afterSave(entity, isNew);
305
- }
306
- return entity;
307
- }
308
- /**
309
- * Save multiple entities in a single optimised batch.
310
- *
311
- * 1. Captures the sheet reference and row count once (K1, B7).
312
- * 2. Calls {@link doSave} for each entity — rows are buffered instead of
313
- * written individually.
314
- * 3. Flushes all buffered rows via {@link flushEntityBatch} (single API call).
315
- * 4. Flushes index writes via {@link IndexStore.flushIndexBatch}.
316
- *
317
- * On error the batch state is fully reset and the cache is invalidated.
318
- *
319
- * @param entities - Array of partial entities to save.
320
- * @returns Array of fully populated saved entities.
321
- */
322
- saveAll(entities) {
323
- if (entities.length === 0)
324
- return [];
325
- const sheet = this.getSheet();
326
- SheetOrmLogger.log(`[Repo:${this.schema.tableName}] saveAll START — ${entities.length} entities`);
327
- // Initialise batch state
328
- this.entityBatch = [];
329
- this.batchSheet = sheet;
330
- // Reuse physicalRowCount when available — avoids a getLastRow() API call (~700 ms)
331
- // when the row count is already known in-session (e.g. new table seeded to 0).
332
- this.batchBaseRowCount = this.physicalRowCount !== null ? this.physicalRowCount : sheet.getRowCount();
333
- if (this.schema.indexTableName) {
334
- this.indexStore.beginIndexBatch();
335
- // Pre-fetch indexed fields once — avoids N × getIndexedFields() array allocation
336
- this.batchIndexedFields = this.indexStore.getIndexedFields(this.schema.indexTableName);
337
- }
338
- try {
339
- // Execute all saves (rows buffered into this.entityBatch)
340
- const results = entities.map((e) => this.doSave(e));
341
- // Count how many new rows were created to update physicalRowCount
342
- const savedCreates = (this.entityBatch ?? []).filter((i) => i.mode === "create").length;
343
- // Flush buffered rows to the sheet in one updateRows() call
344
- this.flushEntityBatch(sheet);
345
- // Flush index batch (single write per index sheet)
346
- if (this.schema.indexTableName) {
347
- this.indexStore.flushIndexBatch();
348
- }
349
- // Update physicalRowCount with the number of new rows
350
- if (this.batchBaseRowCount !== null) {
351
- this.physicalRowCount = this.batchBaseRowCount + savedCreates;
352
- }
353
- // Clear batch state
354
- this.batchSheet = null;
355
- this.batchBaseRowCount = null;
356
- this.batchCachedData = null;
357
- this.batchIndexedFields = null;
358
- SheetOrmLogger.log(`[Repo:${this.schema.tableName}] saveAll DONE — ${entities.length} entities`);
359
- return results;
360
- }
361
- catch (err) {
362
- // Error recovery: clear all batch state and invalidate caches
363
- this.entityBatch = null;
364
- this.batchSheet = null;
365
- this.batchBaseRowCount = null;
366
- this.batchCachedData = null;
367
- this.batchIndexedFields = null;
368
- if (this.schema.indexTableName) {
369
- this.indexStore.cancelIndexBatch();
370
- }
371
- if (this.cache)
372
- this.cache.delete(this.dataCacheKey);
373
- this.idToRowIndex = null;
374
- this.physicalRowCount = null;
375
- this.idToRowIndexComplete = false;
376
- throw err;
377
- }
378
- }
379
- /**
380
- * Find an entity by its primary key (`__id`).
381
- *
382
- * Uses a fast path when idToRowIndex + cache are populated, falling back
383
- * to a full {@link loadAllEntities} scan otherwise.
384
- *
385
- * @param id - Entity `__id` value.
386
- * @returns The matching entity or `null` if not found.
387
- */
388
- findById(id) {
389
- // Fast path: hit cached row-index map to avoid full scan
390
- if (this.idToRowIndex && this.cache) {
391
- const rowIdx = this.idToRowIndex.get(id);
392
- if (rowIdx === undefined)
393
- return null;
394
- const cached = this.cache.get(this.dataCacheKey);
395
- if (cached) {
396
- const hit = cached[rowIdx];
397
- // Validate ID — gap rows may cause cache index drift
398
- if (hit?.__id === id)
399
- return this.cloneEntity(hit);
400
- // Index/cache divergence (e.g. gap rows): scan cached array directly
401
- // to avoid unnecessary sheet re-read via loadAllEntities()
402
- const found = cached.find((e) => e?.__id === id);
403
- return found ? this.cloneEntity(found) : null;
404
- }
405
- }
406
- // Slow path: load all entities from sheet (populates cache as a side-effect)
407
- const all = this.loadAllEntities();
408
- const found = all.find((e) => e.__id === id);
409
- return found ? this.cloneEntity(found) : null;
410
- }
411
- /**
412
- * Find entities matching query options (filter, sort, paginate, group).
413
- *
414
- * When a `search` operator targets an `@Indexed` field and a combined index
415
- * sheet exists, the n-gram search index is used to narrow candidates before
416
- * the full filter pipeline runs (Solr-like optimisation).
417
- *
418
- * @param options - Optional query options (where, whereGroups, orderBy, offset, limit).
419
- * @returns Array of matching entities.
420
- */
421
- find(options) {
422
- const all = this.loadAllEntities();
423
- if (!options)
424
- return this.cloneEntities(all);
425
- if (options.where && !options.whereGroups && this.schema.indexTableName) {
426
- // Separate search-operator filters (n-gram indexed) from other filters
427
- const searchFilters = [];
428
- const otherFilters = [];
429
- for (const f of options.where) {
430
- if (f.operator === "search" && this.isIndexedField(f.field)) {
431
- searchFilters.push({ field: f.field, value: String(f.value) });
432
- }
433
- else {
434
- otherFilters.push(f);
435
- }
436
- }
437
- // N-gram index optimisation: narrow candidates via IndexStore.searchCombined
438
- // before running the full filter pipeline (analogous to Solr pre-filtering)
439
- if (searchFilters.length > 0) {
440
- let candidateIds = null;
441
- for (const sf of searchFilters) {
442
- const ids = this.indexStore.searchCombined(this.schema.indexTableName, sf.field, sf.value);
443
- const idSet = new Set(ids);
444
- // Intersect candidate sets across multiple search filters (AND semantics)
445
- if (candidateIds === null) {
446
- candidateIds = idSet;
447
- }
448
- else {
449
- for (const id of candidateIds) {
450
- if (!idSet.has(id))
451
- candidateIds.delete(id);
452
- }
453
- }
454
- }
455
- if (!candidateIds || candidateIds.size === 0)
456
- return [];
457
- // Filter the in-memory entities to only those matching the index hits
458
- const narrowed = all.filter((e) => candidateIds.has(e.__id));
459
- return this.cloneEntities(QueryEngine.executeQuery(narrowed, {
460
- ...options,
461
- where: otherFilters.length > 0 ? otherFilters : undefined,
462
- }));
463
- }
464
- }
465
- // Standard path: run full QueryEngine pipeline on all entities
466
- return this.cloneEntities(QueryEngine.executeQuery(all, options));
467
- }
468
- /**
469
- * Find the first entity matching query options.
470
- *
471
- * Delegates to {@link find} with `limit: 1`.
472
- *
473
- * @param options - Optional query options.
474
- * @returns The first matching entity or `null`.
475
- */
476
- findOne(options) {
477
- const opts = { ...options, limit: 1 };
478
- const results = this.find(opts);
479
- return results.length > 0 ? results[0] : null;
480
- }
481
- /**
482
- * Delete an entity by ID.
483
- *
484
- * When a batch is active, the delete is queued and `true` is returned
485
- * immediately; actual removal happens on {@link commitBatch}.
486
- *
487
- * @param id - Entity `__id` to delete.
488
- * @returns `true` if the entity was found and deleted (or queued).
489
- */
490
- delete(id) {
491
- if (this.batchBuffer) {
492
- this.batchBuffer.push({ type: "delete", data: id });
493
- return true;
494
- }
495
- return this.doDelete(id);
496
- }
497
- /**
498
- * Internal delete implementation.
499
- *
500
- * 1. Runs `beforeDelete` lifecycle hook (can veto deletion by returning `false`).
501
- * 2. Resolves the row index via in-memory cache or sheet scan.
502
- * 3. Removes from secondary indexes.
503
- * 4. Deletes the sheet row and adjusts `idToRowIndex`.
504
- * 5. Updates entity cache and runs `afterDelete` hook.
505
- *
506
- * @param id - Entity `__id` to delete.
507
- * @returns `true` if the entity was found and deleted.
508
- */
509
- doDelete(id) {
510
- // Lifecycle: beforeDelete — can veto deletion by returning false
511
- if (this.hooks.beforeDelete) {
512
- const result = this.hooks.beforeDelete(id);
513
- if (result === false)
514
- return false;
515
- }
516
- const sheet = this.getSheet();
517
- // Fast path: verify entity from in-memory cache — avoids sheet.getRow() API call
518
- // (a getRange().getValues() round-trip to GAS, ~200 ms per call).
519
- let rowIdx = null;
520
- if (this.idToRowIndex) {
521
- const idx = this.idToRowIndex.get(id);
522
- if (idx !== undefined) {
523
- const cached = this.cache?.get(this.dataCacheKey);
524
- if (cached?.[idx]?.__id === id) {
525
- rowIdx = idx;
526
- }
527
- else {
528
- // Cache cold or stale index — verify via sheet read
529
- const row = sheet.getRow(idx);
530
- if (row && String(row[this.idColIdx]) === id) {
531
- rowIdx = idx;
532
- }
533
- else {
534
- // Stale index — fall back to full scan
535
- rowIdx = this.rowIndexById(sheet, id);
536
- }
537
- }
538
- }
539
- }
540
- if (rowIdx === null) {
541
- rowIdx = this.rowIndexById(sheet, id);
542
- }
543
- if (rowIdx === null)
544
- return false;
545
- // Remove from secondary indexes
546
- if (this.schema.indexTableName) {
547
- this.indexStore.removeAllFromCombined(this.schema.indexTableName, id);
548
- }
549
- // Delete the sheet row and adjust the tracked row count
550
- sheet.deleteRow(rowIdx);
551
- if (this.physicalRowCount !== null)
552
- this.physicalRowCount--;
553
- // Update idToRowIndex: remove deleted ID and shift rows above it down by one
554
- if (this.idToRowIndex) {
555
- this.idToRowIndex.delete(id);
556
- for (const [key, idx] of this.idToRowIndex) {
557
- if (idx > rowIdx) {
558
- this.idToRowIndex.set(key, idx - 1);
559
- }
560
- }
561
- }
562
- // Update entity cache in place (remove the deleted entity)
563
- this.updateCacheAfterDelete(id);
564
- // Lifecycle: afterDelete
565
- if (this.hooks.afterDelete) {
566
- this.hooks.afterDelete(id);
567
- }
568
- return true;
569
- }
570
- /**
571
- * Delete all entities matching a query (or all entities if no options given).
572
- *
573
- * Uses bulk write (replaceAllData) for 3+ deletions — two API calls instead
574
- * of N individual deleteRow() calls. For 1–2 deletions, individual deletes
575
- * are cheaper due to lower overhead.
576
- *
577
- * @param options - Optional query options to select entities to delete.
578
- * @returns Number of entities deleted.
579
- */
580
- deleteAll(options) {
581
- // In batch mode: queue deletes for later commitBatch()
582
- if (this.batchBuffer) {
583
- const entities = this.find(options);
584
- for (const entity of entities) {
585
- this.batchBuffer.push({ type: "delete", data: entity.__id });
586
- }
587
- return entities.length;
588
- }
589
- const all = this.loadAllEntities();
590
- const toDelete = options ? QueryEngine.executeQuery(all, options) : [...all];
591
- if (toDelete.length === 0)
592
- return 0;
593
- // For small batches (≤2), individual deletes are cheaper than replaceAllData
594
- if (toDelete.length <= 2) {
595
- let count = 0;
596
- for (const entity of toDelete) {
597
- if (this.doDelete(entity.__id))
598
- count++;
599
- }
600
- return count;
601
- }
602
- // ── Bulk delete: snapshot → filter out deleted → write back (2 API calls vs N) ──
603
- const deleteIds = new Set();
604
- for (const entity of toDelete) {
605
- // beforeDelete can veto individual deletions
606
- if (this.hooks.beforeDelete && this.hooks.beforeDelete(entity.__id) === false)
607
- continue;
608
- deleteIds.add(entity.__id);
609
- }
610
- if (deleteIds.size === 0)
611
- return 0;
612
- // Retain only entities not in the delete set
613
- const remaining = all.filter((e) => !deleteIds.has(e.__id));
614
- const sheet = this.getSheet();
615
- const rows = remaining.map((e) => Serialization.entityToRow(e, this.schema.fields, this.headers, this.fieldMap));
616
- // Replace the entire sheet data with the filtered rows
617
- sheet.replaceAllData(rows);
618
- // Clean up secondary indexes for all deleted entities
619
- if (this.schema.indexTableName) {
620
- this.indexStore.removeMultipleFromCombined(this.schema.indexTableName, [...deleteIds]);
621
- }
622
- // afterDelete hooks for each deleted entity
623
- for (const id of deleteIds) {
624
- if (this.hooks.afterDelete)
625
- this.hooks.afterDelete(id);
626
- }
627
- // Rebuild cache and idToRowIndex from the remaining entities
628
- if (this.cache) {
629
- this.cache.set(this.dataCacheKey, remaining);
630
- }
631
- const rowIndex = new Map();
632
- for (let i = 0; i < remaining.length; i++) {
633
- rowIndex.set(remaining[i].__id, i);
634
- }
635
- this.idToRowIndex = rowIndex;
636
- this.physicalRowCount = rows.length;
637
- this.idToRowIndexComplete = true;
638
- return deleteIds.size;
639
- }
640
- /**
641
- * Count entities matching a query.
642
- *
643
- * @param options - Optional query options (only `where`/`whereGroups` are meaningful).
644
- * @returns Total count of matching entities.
645
- */
646
- count(options) {
647
- const all = this.loadAllEntities();
648
- if (!options || (!options.where && !options.whereGroups))
649
- return all.length;
650
- return QueryEngine.executeQuery(all, options).length;
651
- }
652
- /**
653
- * Paginated select — applies optional filters, then paginates.
654
- *
655
- * @param offset - 0-based offset.
656
- * @param limit - Maximum number of results.
657
- * @param options - Optional query options applied before pagination.
658
- * @returns {@link PaginatedResult} with `data`, `total`, `offset`, and `limit`.
659
- */
660
- select(offset, limit, options) {
661
- let entities = this.cloneEntities(this.loadAllEntities());
662
- if (options) {
663
- // Filter/sort first, then paginate (offset/limit from options are stripped to avoid double application)
664
- entities = QueryEngine.executeQuery(entities, { ...options, offset: undefined, limit: undefined });
665
- }
666
- return QueryEngine.paginateEntities(entities, offset, limit);
667
- }
668
- /**
669
- * Group entities by a field value.
670
- *
671
- * @param field - Field name to group by.
672
- * @param options - Optional query options applied before grouping.
673
- * @returns Array of {@link GroupResult} objects.
674
- */
675
- groupBy(field, options) {
676
- let entities = this.cloneEntities(this.loadAllEntities());
677
- if (options) {
678
- entities = QueryEngine.executeQuery(entities, options);
679
- }
680
- return QueryEngine.groupEntities(entities, field);
681
- }
682
- /**
683
- * Create a fluent {@link Query} builder for this repository.
684
- *
685
- * @returns A new Query instance backed by a snapshot of all current entities.
686
- */
687
- query() {
688
- return new Query(() => this.cloneEntities(this.loadAllEntities()));
689
- }
690
- // ─── Batch Operations ──────────────────────────────
691
- /**
692
- * Start buffering save/delete operations for later atomic commit.
693
- *
694
- * Call {@link commitBatch} to apply all buffered operations, or
695
- * {@link rollbackBatch} to discard them.
696
- */
697
- beginBatch() {
698
- this.batchBuffer = [];
699
- }
700
- /**
701
- * Commit all buffered operations from {@link beginBatch}.
702
- *
703
- * If the buffer contains only deletes, a bulk rewrite (replaceAllData) is
704
- * used instead of N individual deleteRow() calls.
705
- */
706
- commitBatch() {
707
- if (!this.batchBuffer)
708
- return;
709
- const buffer = this.batchBuffer;
710
- this.batchBuffer = null;
711
- try {
712
- // Optimize delete-only batches (common for deleteAll() in batch mode)
713
- // by applying one bulk rewrite instead of N × deleteRow() API calls.
714
- if (buffer.length > 0 && buffer.every((op) => op.type === "delete")) {
715
- this.commitDeleteBatch(buffer.map((op) => op.data));
716
- return;
717
- }
718
- // Mixed batch: replay operations sequentially
719
- for (const op of buffer) {
720
- if (op.type === "save") {
721
- this.doSave(op.data);
722
- }
723
- else if (op.type === "delete") {
724
- this.doDelete(op.data);
725
- }
726
- }
727
- }
728
- catch (err) {
729
- // On error: invalidate caches to avoid stale state
730
- if (this.cache)
731
- this.cache.delete(this.dataCacheKey);
732
- this.idToRowIndex = null;
733
- this.physicalRowCount = null;
734
- this.idToRowIndexComplete = false;
735
- throw err;
736
- }
737
- }
738
- /**
739
- * Commit a delete-only batch via bulk replaceAllData.
740
- *
741
- * Falls back to per-ID deletes for tiny (≤2) or duplicate-heavy batches
742
- * to preserve semantics and avoid unnecessary full-sheet rewrites.
743
- *
744
- * @param ids - Array of entity IDs to delete.
745
- */
746
- commitDeleteBatch(ids) {
747
- if (ids.length === 0)
748
- return;
749
- // For very small batches or duplicates, individual deletes are cheaper
750
- const uniqueCount = new Set(ids).size;
751
- if (ids.length <= 2 || uniqueCount !== ids.length) {
752
- for (const id of ids) {
753
- this.doDelete(id);
754
- }
755
- return;
756
- }
757
- const all = this.loadAllEntities();
758
- if (all.length === 0)
759
- return;
760
- // Build set of existing IDs to skip deletes of already-removed entities
761
- const existingIds = new Set(all.map((e) => e.__id));
762
- const deleteIds = [];
763
- // Preserve caller order; run beforeDelete hooks (can veto)
764
- for (const id of ids) {
765
- if (!existingIds.has(id))
766
- continue;
767
- if (this.hooks.beforeDelete && this.hooks.beforeDelete(id) === false)
768
- continue;
769
- deleteIds.push(id);
770
- }
771
- if (deleteIds.length === 0)
772
- return;
773
- // Filter out deleted entities and rewrite the entire sheet
774
- const deleteSet = new Set(deleteIds);
775
- const remaining = all.filter((e) => !deleteSet.has(e.__id));
776
- const rows = remaining.map((e) => Serialization.entityToRow(e, this.schema.fields, this.headers, this.fieldMap));
777
- const sheet = this.getSheet();
778
- sheet.replaceAllData(rows);
779
- // Clean up secondary indexes for deleted entities
780
- if (this.schema.indexTableName) {
781
- this.indexStore.removeMultipleFromCombined(this.schema.indexTableName, deleteIds);
782
- }
783
- // Run afterDelete hooks
784
- if (this.hooks.afterDelete) {
785
- for (const id of deleteIds) {
786
- this.hooks.afterDelete(id);
787
- }
788
- }
789
- // Rebuild cache and idToRowIndex from the remaining entities
790
- if (this.cache) {
791
- this.cache.set(this.dataCacheKey, remaining);
792
- }
793
- const rowIndex = new Map();
794
- for (let i = 0; i < remaining.length; i++) {
795
- rowIndex.set(remaining[i].__id, i);
796
- }
797
- this.idToRowIndex = rowIndex;
798
- this.physicalRowCount = rows.length;
799
- this.idToRowIndexComplete = true;
800
- }
801
- /**
802
- * Discard all buffered operations without applying them.
803
- */
804
- rollbackBatch() {
805
- this.batchBuffer = null;
806
- }
807
- /**
808
- * Check whether batch mode is currently active.
809
- *
810
- * @returns `true` when {@link beginBatch} was called but not yet committed or rolled back.
811
- */
812
- isBatchActive() {
813
- return this.batchBuffer !== null;
814
- }
815
- // ─── Internal Helpers ──────────────────────────────
816
- /**
817
- * Return the memoised ISheetAdapter for this table, resolving it once
818
- * from the spreadsheet adapter and caching it for subsequent calls (B7).
819
- *
820
- * @throws Error if the sheet doesn't exist.
821
- */
822
- getSheet() {
823
- if (this.sheetCache)
824
- return this.sheetCache;
825
- const sheet = this.adapter.getSheetByName(this.schema.tableName);
826
- if (!sheet) {
827
- throw new Error(`Sheet "${this.schema.tableName}" not found. Ensure Registry is configured and the table has been initialized.`);
828
- }
829
- this.sheetCache = sheet;
830
- return sheet;
831
- }
832
- /**
833
- * Load all entities from the sheet, deserialise them, and populate the cache.
834
- *
835
- * Returns the cached array on a cache hit. On a cache miss, reads all rows
836
- * from the sheet, converts them via {@link Serialization.rowToEntity},
837
- * builds {@link idToRowIndex}, and stores the result.
838
- *
839
- * @throws Error if called during an active entity batch (saveAll).
840
- * @returns Array of all entities in the table.
841
- */
842
- loadAllEntities() {
843
- if (this.entityBatch) {
844
- throw new Error("Cannot reload entities during active entity batch — lifecycle hooks must not trigger find/count during saveAll()");
845
- }
846
- // Cache hit path
847
- if (this.cache) {
848
- const cached = this.cache.get(this.dataCacheKey);
849
- if (cached !== null)
850
- return cached;
851
- }
852
- // Cache miss: read all rows from the sheet
853
- const sheet = this.getSheet();
854
- const data = sheet.getAllData();
855
- const len = data.length;
856
- const headers = this.headers;
857
- const fields = this.schema.fields;
858
- const fMap = this.fieldMap;
859
- const entities = [];
860
- const rowIndex = new Map();
861
- // Deserialise each row, skipping rows with invalid/missing IDs
862
- for (let i = 0; i < len; i++) {
863
- const entity = Serialization.rowToEntity(data[i], headers, fields, fMap);
864
- if (!entity.__id || entity.__id === "undefined" || entity.__id === "null")
865
- continue;
866
- rowIndex.set(entity.__id, i);
867
- entities.push(entity);
868
- }
869
- // Update in-memory state
870
- this.idToRowIndex = rowIndex;
871
- this.physicalRowCount = data.length;
872
- this.idToRowIndexComplete = true;
873
- if (this.cache) {
874
- this.cache.set(this.dataCacheKey, entities);
875
- }
876
- return entities;
877
- }
878
- /**
879
- * Find the 0-based data row index for an entity by its `__id` via a full
880
- * sheet scan. Rebuilds {@link idToRowIndex} as a side-effect.
881
- *
882
- * @param sheet - The sheet adapter to read from.
883
- * @param id - Entity `__id` to search for.
884
- * @returns 0-based row index, or `null` if not found.
885
- */
886
- rowIndexById(sheet, id) {
887
- const data = sheet.getAllData();
888
- const col = this.idColIdx;
889
- if (col < 0)
890
- return null;
891
- // Populate idToRowIndex while scanning (we already have all the data)
892
- const rowIndex = new Map();
893
- let result = null;
894
- for (let i = 0; i < data.length; i++) {
895
- const rowId = String(data[i][col]);
896
- rowIndex.set(rowId, i);
897
- if (rowId === id)
898
- result = i;
899
- }
900
- this.idToRowIndex = rowIndex;
901
- this.physicalRowCount = data.length;
902
- this.idToRowIndexComplete = true;
903
- return result;
904
- }
905
- /**
906
- * Add a newly-created entity to all relevant secondary indexes.
907
- *
908
- * In batch mode, uses pre-cached {@link batchIndexedFields} to avoid
909
- * repeated `getIndexedFields()` allocations.
910
- *
911
- * @param entity - The entity to index.
912
- */
913
- addToIndexes(entity) {
914
- if (this.schema.indexTableName) {
915
- // Use pre-cached fields in batch mode to avoid N × getIndexedFields() array allocation
916
- const indexedFields = this.batchIndexedFields ?? this.indexStore.getIndexedFields(this.schema.indexTableName);
917
- const entries = [];
918
- for (const meta of indexedFields) {
919
- const value = entity[meta.field];
920
- if (value !== undefined && value !== null && value !== "") {
921
- entries.push({ field: meta.field, value });
922
- }
923
- }
924
- if (entries.length > 0) {
925
- this.indexStore.addAllFieldsToCombined(this.schema.indexTableName, entries, entity.__id);
926
- }
927
- }
928
- }
929
- /**
930
- * Flush all buffered entity rows to the sheet in a single API call.
931
- *
932
- * Called at the end of {@link saveAll}. Sorts buffered entries by
933
- * `dataIndex` to build a contiguous row array, then writes them via
934
- * `sheet.updateRows()` (or `writeAllRowsWithHeaders` for the L1 path).
935
- *
936
- * @param sheet - The sheet adapter to write to.
937
- */
938
- flushEntityBatch(sheet) {
939
- if (!this.entityBatch || this.entityBatch.length === 0) {
940
- this.entityBatch = null;
941
- return;
942
- }
943
- const batch = this.entityBatch;
944
- this.entityBatch = null;
945
- const creates = batch.filter((i) => i.mode === "create").length;
946
- const updates = batch.filter((i) => i.mode === "update").length;
947
- SheetOrmLogger.log(`[Repo:${this.schema.tableName}] flushEntityBatch ${batch.length} rows (creates=${creates} updates=${updates}); calling sheet.updateRows()`);
948
- // Sort by dataIndex for contiguous writes
949
- const sorted = [...batch].sort((a, b) => a.dataIndex - b.dataIndex);
950
- // Write header row + data rows in a single API call for newly-created sheets
951
- if (this.headersDeferred) {
952
- sheet.writeAllRowsWithHeaders(this.headers, sorted.map((item) => item.row));
953
- this.headersDeferred = false;
954
- }
955
- else {
956
- sheet.updateRows(sorted.map((item) => ({ rowIndex: item.dataIndex, values: item.row })));
957
- }
958
- }
959
- /** Shallow-clone an entity to prevent external mutation of cached data. */
960
- cloneEntity(entity) {
961
- return { ...entity };
962
- }
963
- /** Shallow-clone an array of entities. */
964
- cloneEntities(entities) {
965
- return entities.map((entity) => this.cloneEntity(entity));
966
- }
967
- /**
968
- * Update the entity cache in place after a save (create or update).
969
- *
970
- * In batch mode, reuses {@link batchCachedData} to avoid repeated
971
- * `cache.get()` calls that would trigger verbose logging overhead.
972
- *
973
- * @param entity - The saved entity.
974
- * @param isNew - Whether this was a create (append) or update (replace).
975
- */
976
- updateCacheAfterSave(entity, isNew) {
977
- if (!this.cache)
978
- return;
979
- // In batch mode, reuse pre-fetched array ref to avoid N × cache.get() Logger.log() calls in GAS.
980
- // batchCachedData is lazily initialised here on the first entity (after the cache entry is created
981
- // by the CREATE path in doSave for entity #1 when dataIndex === 0).
982
- let cached;
983
- if (this.entityBatch !== null) {
984
- if (!this.batchCachedData) {
985
- this.batchCachedData = this.cache.get(this.dataCacheKey);
986
- }
987
- cached = this.batchCachedData;
988
- }
989
- else {
990
- cached = this.cache.get(this.dataCacheKey);
991
- }
992
- if (!cached)
993
- return;
994
- if (isNew) {
995
- // Append new entity to the cached array
996
- cached.push(entity);
997
- }
998
- else {
999
- // Replace existing entity in the cached array
1000
- for (let i = 0; i < cached.length; i++) {
1001
- if (cached[i]?.__id === entity.__id) {
1002
- cached[i] = entity;
1003
- return;
1004
- }
1005
- }
1006
- }
1007
- }
1008
- /**
1009
- * Remove a deleted entity from the entity cache.
1010
- *
1011
- * Note: {@link idToRowIndex} is updated separately in {@link doDelete}.
1012
- * This method only handles the entity array cache.
1013
- *
1014
- * @param id - The `__id` of the deleted entity.
1015
- */
1016
- updateCacheAfterDelete(id) {
1017
- if (!this.cache)
1018
- return;
1019
- const cached = this.cache.get(this.dataCacheKey);
1020
- if (!cached)
1021
- return;
1022
- for (let i = 0; i < cached.length; i++) {
1023
- if (cached[i]?.__id === id) {
1024
- cached.splice(i, 1);
1025
- return;
1026
- }
1027
- }
1028
- }
1029
- /**
1030
- * Check whether a field name has an `@Indexed` decorator.
1031
- *
1032
- * Uses the pre-built {@link indexedFieldNames} set for O(1) lookup.
1033
- *
1034
- * @param fieldName - Name of the field to check.
1035
- * @returns `true` if the field is indexed.
1036
- */
1037
- isIndexedField(fieldName) {
1038
- return this.indexedFieldNames.has(fieldName);
1039
- }
1040
- }
1041
- //# sourceMappingURL=SheetRepository.js.map