@b2bc-devkit/sheetorm 1.2.3 → 1.2.4
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 +184 -0
- package/dist/npm/core/Record.d.ts.map +1 -0
- package/dist/npm/core/Record.js +321 -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 +25 -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 +101 -0
- package/dist/npm/core/Registry.d.ts.map +1 -0
- package/dist/npm/core/Registry.js +231 -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 +53 -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 +49 -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 +397 -0
- package/dist/npm/index/IndexStore.d.ts.map +1 -0
- package/dist/npm/index/IndexStore.js +1102 -0
- package/dist/npm/index/IndexStore.js.map +1 -0
- package/dist/npm/index.d.ts +117 -0
- package/dist/npm/index.d.ts.map +1 -0
- package/dist/npm/index.js +371 -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 +119 -0
- package/dist/npm/storage/GoogleSheetAdapter.d.ts.map +1 -0
- package/dist/npm/storage/GoogleSheetAdapter.js +238 -0
- package/dist/npm/storage/GoogleSheetAdapter.js.map +1 -0
- package/dist/npm/storage/GoogleSpreadsheetAdapter.d.ts +71 -0
- package/dist/npm/storage/GoogleSpreadsheetAdapter.d.ts.map +1 -0
- package/dist/npm/storage/GoogleSpreadsheetAdapter.js +131 -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 +407 -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 +66 -0
- package/dist/npm/testing/RuntimeParity.d.ts.map +1 -0
- package/dist/npm/testing/RuntimeParity.js +4193 -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/package.json +3 -2
|
@@ -0,0 +1,1041 @@
|
|
|
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
|