@b2bc-devkit/sheetorm 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/dist/npm/SheetOrm.d.ts +41 -0
  2. package/dist/npm/SheetOrm.d.ts.map +1 -0
  3. package/dist/npm/SheetOrm.js +41 -0
  4. package/dist/npm/SheetOrm.js.map +1 -0
  5. package/dist/npm/core/Decorators.d.ts +145 -0
  6. package/dist/npm/core/Decorators.d.ts.map +1 -0
  7. package/dist/npm/core/Decorators.js +226 -0
  8. package/dist/npm/core/Decorators.js.map +1 -0
  9. package/dist/npm/core/Record.d.ts +167 -0
  10. package/dist/npm/core/Record.d.ts.map +1 -0
  11. package/dist/npm/core/Record.js +298 -0
  12. package/dist/npm/core/Record.js.map +1 -0
  13. package/dist/npm/core/RecordConstructor.d.ts +20 -0
  14. package/dist/npm/core/RecordConstructor.d.ts.map +1 -0
  15. package/dist/npm/core/RecordConstructor.js +2 -0
  16. package/dist/npm/core/RecordConstructor.js.map +1 -0
  17. package/dist/npm/core/RecordStatic.d.ts +19 -0
  18. package/dist/npm/core/RecordStatic.d.ts.map +1 -0
  19. package/dist/npm/core/RecordStatic.js +2 -0
  20. package/dist/npm/core/RecordStatic.js.map +1 -0
  21. package/dist/npm/core/Registry.d.ts +100 -0
  22. package/dist/npm/core/Registry.d.ts.map +1 -0
  23. package/dist/npm/core/Registry.js +206 -0
  24. package/dist/npm/core/Registry.js.map +1 -0
  25. package/dist/npm/core/SheetRepository.d.ts +331 -0
  26. package/dist/npm/core/SheetRepository.d.ts.map +1 -0
  27. package/dist/npm/core/SheetRepository.js +1041 -0
  28. package/dist/npm/core/SheetRepository.js.map +1 -0
  29. package/dist/npm/core/cache/MemoryCache.d.ts +67 -0
  30. package/dist/npm/core/cache/MemoryCache.d.ts.map +1 -0
  31. package/dist/npm/core/cache/MemoryCache.js +112 -0
  32. package/dist/npm/core/cache/MemoryCache.js.map +1 -0
  33. package/dist/npm/core/types/Entity.d.ts +21 -0
  34. package/dist/npm/core/types/Entity.d.ts.map +1 -0
  35. package/dist/npm/core/types/Entity.js +2 -0
  36. package/dist/npm/core/types/Entity.js.map +1 -0
  37. package/dist/npm/core/types/FieldDefinition.d.ts +21 -0
  38. package/dist/npm/core/types/FieldDefinition.d.ts.map +1 -0
  39. package/dist/npm/core/types/FieldDefinition.js +2 -0
  40. package/dist/npm/core/types/FieldDefinition.js.map +1 -0
  41. package/dist/npm/core/types/FieldType.d.ts +12 -0
  42. package/dist/npm/core/types/FieldType.d.ts.map +1 -0
  43. package/dist/npm/core/types/FieldType.js +2 -0
  44. package/dist/npm/core/types/FieldType.js.map +1 -0
  45. package/dist/npm/core/types/Filter.d.ts +16 -0
  46. package/dist/npm/core/types/Filter.d.ts.map +1 -0
  47. package/dist/npm/core/types/Filter.js +2 -0
  48. package/dist/npm/core/types/Filter.js.map +1 -0
  49. package/dist/npm/core/types/FilterOperator.d.ts +16 -0
  50. package/dist/npm/core/types/FilterOperator.d.ts.map +1 -0
  51. package/dist/npm/core/types/FilterOperator.js +2 -0
  52. package/dist/npm/core/types/FilterOperator.js.map +1 -0
  53. package/dist/npm/core/types/GroupResult.d.ts +14 -0
  54. package/dist/npm/core/types/GroupResult.d.ts.map +1 -0
  55. package/dist/npm/core/types/GroupResult.js +2 -0
  56. package/dist/npm/core/types/GroupResult.js.map +1 -0
  57. package/dist/npm/core/types/ICacheProvider.d.ts +20 -0
  58. package/dist/npm/core/types/ICacheProvider.d.ts.map +1 -0
  59. package/dist/npm/core/types/ICacheProvider.js +2 -0
  60. package/dist/npm/core/types/ICacheProvider.js.map +1 -0
  61. package/dist/npm/core/types/ISheetAdapter.d.ts +54 -0
  62. package/dist/npm/core/types/ISheetAdapter.d.ts.map +1 -0
  63. package/dist/npm/core/types/ISheetAdapter.js +2 -0
  64. package/dist/npm/core/types/ISheetAdapter.js.map +1 -0
  65. package/dist/npm/core/types/ISpreadsheetAdapter.d.ts +34 -0
  66. package/dist/npm/core/types/ISpreadsheetAdapter.d.ts.map +1 -0
  67. package/dist/npm/core/types/ISpreadsheetAdapter.js +2 -0
  68. package/dist/npm/core/types/ISpreadsheetAdapter.js.map +1 -0
  69. package/dist/npm/core/types/IndexDefinition.d.ts +16 -0
  70. package/dist/npm/core/types/IndexDefinition.d.ts.map +1 -0
  71. package/dist/npm/core/types/IndexDefinition.js +2 -0
  72. package/dist/npm/core/types/IndexDefinition.js.map +1 -0
  73. package/dist/npm/core/types/LifecycleHooks.d.ts +32 -0
  74. package/dist/npm/core/types/LifecycleHooks.d.ts.map +1 -0
  75. package/dist/npm/core/types/LifecycleHooks.js +2 -0
  76. package/dist/npm/core/types/LifecycleHooks.js.map +1 -0
  77. package/dist/npm/core/types/PaginatedResult.d.ts +18 -0
  78. package/dist/npm/core/types/PaginatedResult.d.ts.map +1 -0
  79. package/dist/npm/core/types/PaginatedResult.js +2 -0
  80. package/dist/npm/core/types/PaginatedResult.js.map +1 -0
  81. package/dist/npm/core/types/QueryOptions.d.ts +21 -0
  82. package/dist/npm/core/types/QueryOptions.d.ts.map +1 -0
  83. package/dist/npm/core/types/QueryOptions.js +2 -0
  84. package/dist/npm/core/types/QueryOptions.js.map +1 -0
  85. package/dist/npm/core/types/SortClause.d.ts +10 -0
  86. package/dist/npm/core/types/SortClause.d.ts.map +1 -0
  87. package/dist/npm/core/types/SortClause.js +2 -0
  88. package/dist/npm/core/types/SortClause.js.map +1 -0
  89. package/dist/npm/core/types/SystemColumns.d.ts +16 -0
  90. package/dist/npm/core/types/SystemColumns.d.ts.map +1 -0
  91. package/dist/npm/core/types/SystemColumns.js +16 -0
  92. package/dist/npm/core/types/SystemColumns.js.map +1 -0
  93. package/dist/npm/core/types/TableSchema.d.ts +24 -0
  94. package/dist/npm/core/types/TableSchema.d.ts.map +1 -0
  95. package/dist/npm/core/types/TableSchema.js +2 -0
  96. package/dist/npm/core/types/TableSchema.js.map +1 -0
  97. package/dist/npm/index/IndexMeta.d.ts +16 -0
  98. package/dist/npm/index/IndexMeta.d.ts.map +1 -0
  99. package/dist/npm/index/IndexMeta.js +2 -0
  100. package/dist/npm/index/IndexMeta.js.map +1 -0
  101. package/dist/npm/index/IndexStore.d.ts +399 -0
  102. package/dist/npm/index/IndexStore.d.ts.map +1 -0
  103. package/dist/npm/index/IndexStore.js +1104 -0
  104. package/dist/npm/index/IndexStore.js.map +1 -0
  105. package/dist/npm/index.d.ts +115 -0
  106. package/dist/npm/index.d.ts.map +1 -0
  107. package/dist/npm/index.js +367 -0
  108. package/dist/npm/index.js.map +1 -0
  109. package/dist/npm/query/Query.d.ts +176 -0
  110. package/dist/npm/query/Query.d.ts.map +1 -0
  111. package/dist/npm/query/Query.js +270 -0
  112. package/dist/npm/query/Query.js.map +1 -0
  113. package/dist/npm/query/QueryEngine.d.ts +140 -0
  114. package/dist/npm/query/QueryEngine.d.ts.map +1 -0
  115. package/dist/npm/query/QueryEngine.js +453 -0
  116. package/dist/npm/query/QueryEngine.js.map +1 -0
  117. package/dist/npm/storage/GoogleSheetAdapter.d.ts +120 -0
  118. package/dist/npm/storage/GoogleSheetAdapter.d.ts.map +1 -0
  119. package/dist/npm/storage/GoogleSheetAdapter.js +239 -0
  120. package/dist/npm/storage/GoogleSheetAdapter.js.map +1 -0
  121. package/dist/npm/storage/GoogleSpreadsheetAdapter.d.ts +55 -0
  122. package/dist/npm/storage/GoogleSpreadsheetAdapter.d.ts.map +1 -0
  123. package/dist/npm/storage/GoogleSpreadsheetAdapter.js +99 -0
  124. package/dist/npm/storage/GoogleSpreadsheetAdapter.js.map +1 -0
  125. package/dist/npm/testing/ParityCatalog.d.ts +38 -0
  126. package/dist/npm/testing/ParityCatalog.d.ts.map +1 -0
  127. package/dist/npm/testing/ParityCatalog.js +400 -0
  128. package/dist/npm/testing/ParityCatalog.js.map +1 -0
  129. package/dist/npm/testing/RuntimeBenchmark.d.ts +37 -0
  130. package/dist/npm/testing/RuntimeBenchmark.d.ts.map +1 -0
  131. package/dist/npm/testing/RuntimeBenchmark.js +449 -0
  132. package/dist/npm/testing/RuntimeBenchmark.js.map +1 -0
  133. package/dist/npm/testing/RuntimeParity.d.ts +62 -0
  134. package/dist/npm/testing/RuntimeParity.d.ts.map +1 -0
  135. package/dist/npm/testing/RuntimeParity.js +4030 -0
  136. package/dist/npm/testing/RuntimeParity.js.map +1 -0
  137. package/dist/npm/utils/Serialization.d.ts +111 -0
  138. package/dist/npm/utils/Serialization.d.ts.map +1 -0
  139. package/dist/npm/utils/Serialization.js +281 -0
  140. package/dist/npm/utils/Serialization.js.map +1 -0
  141. package/dist/npm/utils/SheetOrmLogger.d.ts +31 -0
  142. package/dist/npm/utils/SheetOrmLogger.d.ts.map +1 -0
  143. package/dist/npm/utils/SheetOrmLogger.js +42 -0
  144. package/dist/npm/utils/SheetOrmLogger.js.map +1 -0
  145. package/dist/npm/utils/Uuid.d.ts +29 -0
  146. package/dist/npm/utils/Uuid.d.ts.map +1 -0
  147. package/dist/npm/utils/Uuid.js +86 -0
  148. package/dist/npm/utils/Uuid.js.map +1 -0
  149. package/license.md +18 -0
  150. package/package.json +77 -0
  151. package/readme.md +672 -0
package/readme.md ADDED
@@ -0,0 +1,672 @@
1
+ # SheetORM
2
+
3
+ A TypeScript ORM for Google Sheets running in Google Apps Script (GAS). SheetORM brings a structured,
4
+ type-safe persistence layer to spreadsheet-based applications with an **ActiveRecord** API — define a class,
5
+ extend `Record`, and everything just works.
6
+
7
+ ## Features
8
+
9
+ - **ActiveRecord pattern** — Extend `Record`, define fields, and call `save()` / `find()` / `delete()`
10
+ directly on instances and classes
11
+ - **Zero configuration** — Tables, schemas, indexes, and repositories are auto-created on first use
12
+ - **Predictable naming** — Sheet names follow `tbl_{ClassName}s` (e.g. `tbl_Cars`); combined index sheets
13
+ follow `idx_{ClassName}s` (e.g. `idx_Cars`)
14
+ - **Fluent query builder** — `where()`, `and()`, `or()`, `orderBy()`, `limit()`, `offset()`
15
+ - **`Query.from()`** — Start queries from a class reference or string name
16
+ - **Secondary indexes** — Stored in a single combined index sheet (`idx_{ClassName}s`) for fast lookup by
17
+ indexed fields
18
+ - **N-gram text search** — Solr-like trigram search on `@Indexed` fields via `IndexStore.searchCombined()`;
19
+ also available as the `search` filter operator in queries
20
+ - **In-memory caching** — Configurable TTL cache to reduce sheet reads
21
+ - **Lifecycle hooks** — `beforeSave`, `afterSave`, `beforeDelete`, `afterDelete`
22
+ - **Batch operations** — `beginBatch` / `commitBatch` / `rollbackBatch` for safe bulk writes
23
+ - **Pagination & grouping** — `select()` returns `PaginatedResult<T>`, `groupBy()` returns `GroupResult<T>`
24
+ - **Zero runtime dependencies** — Bundles into a single `Code.js` via Vite
25
+
26
+ ## Quick Start
27
+
28
+ ### 1. Install & build
29
+
30
+ ```bash
31
+ npm install
32
+ npm run build
33
+ ```
34
+
35
+ ### 2. Deploy to Google Apps Script
36
+
37
+ ```bash
38
+ npm run login # once per device
39
+ node scripts/publish-gas.mjs # build + push + version (+ deploy if GAS_DEPLOYMENT_ID is set)
40
+ ```
41
+
42
+ ### 3. Define a model
43
+
44
+ Extend `Record` and declare fields as plain TypeScript properties. Use `@Indexed()` for indexed fields,
45
+ `@Required()` for required fields, and `@Field()` for extra options. Undecorated properties are
46
+ auto-discovered as fields.
47
+
48
+ ```ts
49
+ import { Record } from "./src/core/Record";
50
+ import { Decorators } from "./src/core/Decorators";
51
+
52
+ const { Indexed, Required } = Decorators;
53
+
54
+ class Car extends Record {
55
+ @Indexed()
56
+ make: string;
57
+
58
+ @Required()
59
+ model: string;
60
+
61
+ year: number;
62
+ color: string;
63
+ }
64
+ ```
65
+
66
+ - No `tableName` needed in the common case — the sheet name defaults to `tbl_` + class name + `s` (`Car` →
67
+ `tbl_Cars`)
68
+ - When any field is decorated with `@Indexed()`, a combined index sheet is automatically created with the name
69
+ `idx_` + class name + `s` (`Car` → `idx_Cars`). Classes without `@Indexed` fields do **not** get an index
70
+ sheet.
71
+ - `@Indexed()` — marks a field as a secondary index (also implies `@Field`)
72
+ - `@Required()` — marks a field as required using concise decorator syntax
73
+ - `@Field(...)` — adds extra field options like `type`, `defaultValue`, or `referenceTable`
74
+ - Plain properties (`year`, `color`) — auto-discovered as schema fields with type inferred at runtime
75
+
76
+ If you want a custom sheet name, override the static getters:
77
+
78
+ ```ts
79
+ class ArchivedCar extends Record {
80
+ static override get tableName() {
81
+ return "ArchivedCars";
82
+ }
83
+
84
+ static override get indexTableName() {
85
+ return "idx_ArchivedCars";
86
+ }
87
+ }
88
+ ```
89
+
90
+ ### Available decorators
91
+
92
+ SheetORM supports three TypeScript property decorators on `Record` models.
93
+
94
+ #### `@Required()`
95
+
96
+ Use `@Required()` when a field must be present before `save()` succeeds.
97
+
98
+ ```ts
99
+ class Car extends Record {
100
+ @Required()
101
+ model: string;
102
+ }
103
+ ```
104
+
105
+ This decorator is shorthand for `@Field({ required: true })`.
106
+
107
+ #### `@Field(options?)`
108
+
109
+ Use `@Field()` when you want to describe schema metadata explicitly.
110
+
111
+ ```ts
112
+ class Car extends Record {
113
+ @Field({ type: "date" })
114
+ purchasedAt: Date;
115
+
116
+ @Field({ type: "reference", referenceTable: "Owners" })
117
+ ownerId: string;
118
+ }
119
+ ```
120
+
121
+ Supported options:
122
+
123
+ | Option | Type | Description |
124
+ | ---------------- | ----------- | ---------------------------------------- |
125
+ | `required` | `boolean` | Rejects save when the field is missing |
126
+ | `type` | `FieldType` | Overrides runtime type inference |
127
+ | `defaultValue` | `unknown` | Value used when the field is empty |
128
+ | `referenceTable` | `string` | Target table name for `reference` fields |
129
+
130
+ #### `@Indexed(options?)`
131
+
132
+ Use `@Indexed()` when the field should have a secondary index for faster lookups.
133
+
134
+ ```ts
135
+ class Car extends Record {
136
+ @Indexed()
137
+ make: string;
138
+
139
+ @Indexed({ unique: true })
140
+ vin: string;
141
+
142
+ @Indexed({ type: "date" })
143
+ registeredAt: Date;
144
+ }
145
+ ```
146
+
147
+ Supported options:
148
+
149
+ | Option | Type | Description |
150
+ | -------- | -------------------------------- | -------------------------------- |
151
+ | `unique` | `boolean` | Enforces uniqueness in the index |
152
+ | `type` | `"string" \| "number" \| "date"` | Controls index value typing |
153
+
154
+ #### Plain properties without annotations
155
+
156
+ Not everything needs a decorator. Plain class properties are still discovered automatically and become normal
157
+ schema fields:
158
+
159
+ ```ts
160
+ class Car extends Record {
161
+ make: string;
162
+ model: string;
163
+ year: number;
164
+ }
165
+ ```
166
+
167
+ Use decorators only when you need schema metadata or indexing behaviour.
168
+
169
+ ### 4. Use it
170
+
171
+ ```ts
172
+ // Create — table auto-created on first save
173
+ const car = new Car();
174
+ car.make = "Toyota";
175
+ car.model = "Corolla";
176
+ car.year = 2024;
177
+ car.color = "blue";
178
+ car.save();
179
+
180
+ // Or use the static factory
181
+ const civic = Car.create({ make: "Honda", model: "Civic", year: 2023, color: "white" });
182
+ civic.save();
183
+
184
+ // Fluent set + save (chainable)
185
+ new Car().set("make", "Honda").set("model", "Civic").set("year", 2023).save();
186
+
187
+ // Static queries — return typed Car[]
188
+ const toyotas = Car.where("make", "=", "Toyota").execute();
189
+ const found = Car.findById(car.__id);
190
+ const all = Car.find();
191
+
192
+ // Query.from() — class ref (typed) or string
193
+ const recent = Query.from(Car).where("year", ">=", 2023).orderBy("year", "desc").limit(10).execute();
194
+
195
+ // Update
196
+ car.color = "red";
197
+ car.save();
198
+
199
+ // Delete
200
+ car.delete();
201
+
202
+ // Count, pagination, grouping
203
+ Car.count();
204
+ Car.select(0, 10);
205
+ Car.groupBy("make");
206
+ ```
207
+
208
+ See [`examples/cars-crud.ts`](examples/cars-crud.ts) for a complete runnable example.
209
+
210
+ ## Publishing (Apps Script & npm)
211
+
212
+ ### Apps Script library
213
+
214
+ - Prerequisites: `npm run login` (clasp), `appsscript.json` configured for your script, and a Script ID to share.
215
+ - Run `npm run publish:gas` (or VS Code task “Publish: Apps Script library”). It lints, tests, builds, pushes via `clasp push -f`, creates a new version with `GAS_VERSION_MESSAGE` (default: “SheetORM Apps Script release”), and, if `GAS_DEPLOYMENT_ID` is set, deploys with that description.
216
+ - In the Apps Script editor, add the library by Script ID. Use `GasEntrypoints.Record`, `GasEntrypoints.Query`, and `GasEntrypoints.Decorators` in your GAS project; callable menu functions stay limited to `runTests`, `validateTests`, and `runBenchmark`.
217
+
218
+ ### npm package
219
+
220
+ - Build distributable JS + type definitions with `npm run build:npm` (emits to `dist/npm`).
221
+ - Vite stays dedicated to the GAS bundle; the npm build is plain TypeScript output so consumers can use their own bundler.
222
+ - Publish with `node scripts/publish-npm.mjs` (or VS Code task “Publish: npm package”); set `NPM_TAG` to publish under a dist-tag if needed. The script runs lint, tests, build, then `npm publish --access public`.
223
+ - Import via the bundled aggregator:
224
+
225
+ ```ts
226
+ import { SheetOrm } from "sheetorm";
227
+
228
+ const { Record, Query, Decorators } = SheetOrm;
229
+ ```
230
+
231
+ - Or import individual surfaces directly: `import { Record } from "sheetorm/core/Record";`, `import { Query } from "sheetorm/query/Query";`, `import { Decorators } from "sheetorm/core/Decorators";`.
232
+
233
+ ### VS Code tasks
234
+
235
+ Two tasks are available in `.vscode/tasks.json`:
236
+
237
+ - `Publish: Apps Script library` → runs `npm run publish:gas`
238
+ - `Publish: npm package` → runs `npm run publish:npm`
239
+
240
+ ## Architecture
241
+
242
+ ```
243
+ src/
244
+ core/Record.ts — ActiveRecord base class (primary API); tableName = tbl_{Name}s, indexTableName = idx_{Name}s
245
+ core/RecordConstructor.ts — Constructor contract for Record subclasses
246
+ core/RecordStatic.ts — Registry-facing static Record contract
247
+ core/Registry.ts — Global singleton: adapter, repos, class map
248
+ core/SheetRepository.ts — Generic repository: CRUD, batch, hooks, cache
249
+ core/types/ — One exported type / interface / class per file
250
+ Entity.ts — Entity
251
+ FieldType.ts — FieldType
252
+ FieldDefinition.ts — FieldDefinition
253
+ IndexDefinition.ts — IndexDefinition
254
+ TableSchema.ts — TableSchema
255
+ FilterOperator.ts — FilterOperator
256
+ Filter.ts — Filter
257
+ SortClause.ts — SortClause
258
+ QueryOptions.ts — QueryOptions
259
+ PaginatedResult.ts — PaginatedResult
260
+ GroupResult.ts — GroupResult
261
+ LifecycleHooks.ts — LifecycleHooks
262
+ ISheetAdapter.ts — ISheetAdapter
263
+ ISpreadsheetAdapter.ts — ISpreadsheetAdapter
264
+ ICacheProvider.ts — ICacheProvider
265
+ SystemColumns.ts — SystemColumns
266
+ query/Query.ts — Fluent query API + Query.from()
267
+ query/QueryEngine.ts — filter, sort, paginate, group pipeline
268
+ index/IndexMeta.ts — Index metadata contract
269
+ index/IndexStore.ts — Secondary index management
270
+ storage/GoogleSheetAdapter.ts — Native GAS sheet wrapper
271
+ storage/GoogleSpreadsheetAdapter.ts — Native GAS spreadsheet wrapper
272
+ storage/SheetsAPIv4SheetAdapter.ts — Buffered Sheets API v4 sheet wrapper
273
+ storage/SheetsAPIv4SpreadsheetAdapter.ts — Buffered Sheets API v4 spreadsheet wrapper
274
+ core/cache/MemoryCache.ts — In-memory cache implementation
275
+ utils/Uuid.ts — UUID v4 generation (GAS / fallback)
276
+ utils/Serialization.ts — Row ↔ Entity conversion
277
+ testing/ParityCatalog.ts — Canonical Jest ↔ runtime test case list
278
+ testing/RuntimeParity.ts — GAS runtime parity suite
279
+ testing/RuntimeBenchmark.ts — GAS runtime benchmark (Cars + Workers, 1 000 records)
280
+ SheetOrm.ts — npm-facing aggregator for Record/Query/Decorators
281
+ index.ts — `GasEntrypoints` export for GAS globals
282
+ examples/
283
+ cars-crud.ts — Full ActiveRecord example
284
+ ```
285
+
286
+ ## Code Organization Rules
287
+
288
+ Repository conventions are intentionally strict:
289
+
290
+ - one exported class / interface / type / enum per file
291
+ - no barrel files and no multi-export "grab bag" modules
292
+ - exported free functions are not allowed; behavior should live on classes as static methods
293
+ - filenames must match the exported artifact name (for example `Record.ts`, `QueryOptions.ts`, `SystemColumns.ts`)
294
+ - internal helpers may stay private to a file, but the file's public surface must stay singular and obvious
295
+
296
+ When adding new files, follow these rules first and treat them as part of the repository contract.
297
+
298
+ ## API Reference
299
+
300
+ ### Record (ActiveRecord base class)
301
+
302
+ Extend `Record` to define a model. Declare fields as plain class properties — they are auto-discovered. Use
303
+ decorators and an optional static property to customize behavior:
304
+
305
+ | Decorator / property | Description |
306
+ | ----------------------------- | ---------------------------------------------------------------------------- |
307
+ | `@Required()` | Shorthand for marking a field as required |
308
+ | `@Field(options?)` | Explicit field with options (required, type, defaultValue) |
309
+ | `@Indexed(options?)` | Secondary index (implies `@Field`); auto-creates `idx_{ClassName}s` |
310
+ | `static get tableName()` | Sheet name (defaults to `tbl_{ClassName}s` — e.g. `tbl_Cars` for `Car`) |
311
+ | `static get indexTableName()` | Combined index sheet name (defaults to `idx_{ClassName}s` — e.g. `idx_Cars`) |
312
+
313
+ #### `@Required()`
314
+
315
+ Use `@Required()` when a value must be present before saving.
316
+
317
+ | Behavior | Description |
318
+ | ------------------- | -------------------------------------------------------------------------- |
319
+ | Required validation | Rejects `save()` when the field is `undefined`, `null`, or an empty string |
320
+ | Equivalent form | Same as `@Field({ required: true })` |
321
+
322
+ #### `@Field` options
323
+
324
+ | Option | Type | Default | Description |
325
+ | ---------------- | ----------- | ----------- | -------------------------------------------------------------------------- |
326
+ | `required` | `boolean` | `false` | Reject saves when the value is missing |
327
+ | `type` | `FieldType` | auto-infer | Explicit type (`string`, `number`, `boolean`, `date`, `json`, `reference`) |
328
+ | `defaultValue` | `any` | `undefined` | Value used when the field is empty |
329
+ | `referenceTable` | `string` | `undefined` | Target table for `reference` type fields |
330
+
331
+ #### `@Indexed` options
332
+
333
+ | Option | Type | Default | Description |
334
+ | -------- | ----------- | ---------- | ------------------------------- |
335
+ | `unique` | `boolean` | `false` | Enforce uniqueness in the index |
336
+ | `type` | `FieldType` | auto-infer | Index value type |
337
+
338
+ #### Auto-discovered fields
339
+
340
+ Any property declared on a `Record` subclass that is **not** a system column (`__id`, `__createdAt`,
341
+ `__updatedAt`) and is **not** a function is automatically treated as a schema field. Its type is inferred at
342
+ runtime from the value (`typeof`). You only need `@Field()` when you want to set options like `required` or an
343
+ explicit type.
344
+
345
+ > In this repository, examples use direct source imports such as `./src/core/Record`. The GAS bundle itself
346
+ > exposes callable globals through `GasEntrypoints`, not a general-purpose npm package surface.
347
+
348
+ #### Instance methods
349
+
350
+ | Method | Returns | Description |
351
+ | ------------------- | --------- | ----------------------------- |
352
+ | `save()` | `this` | Insert or update (chainable) |
353
+ | `delete()` | `boolean` | Delete from sheet |
354
+ | `set(field, value)` | `this` | Set a field value (chainable) |
355
+ | `get(field)` | `unknown` | Get a field value |
356
+ | `toJSON()` | `object` | Plain object with all fields |
357
+
358
+ #### Static methods
359
+
360
+ | Method | Returns | Description |
361
+ | --------------------------------- | -------------------- | ------------------------------------- |
362
+ | `create(data)` | `T` | Factory: create instance with data |
363
+ | `findById(id)` | `T \| null` | Find by primary key |
364
+ | `find(options?)` | `T[]` | Find all (with optional filters/sort) |
365
+ | `findOne(options?)` | `T \| null` | Find first matching entity |
366
+ | `where(field, op, value)` | `Query<T>` | Start a filtered query chain |
367
+ | `query()` | `Query<T>` | Start an empty query chain |
368
+ | `count(options?)` | `number` | Count matching entities |
369
+ | `deleteAll(options?)` | `number` | Delete matching entities |
370
+ | `select(offset, limit, options?)` | `PaginatedResult<T>` | Paginated query |
371
+ | `groupBy(field, options?)` | `GroupResult<T>[]` | Group by field |
372
+
373
+ ### Query\<T\>
374
+
375
+ ```ts
376
+ Car.where("make", "=", "Toyota")
377
+ .and("year", ">=", 2020)
378
+ .or("color", "=", "red")
379
+ .orderBy("year", "desc")
380
+ .limit(20)
381
+ .offset(40)
382
+ .execute(); // T[]
383
+ // .first() // T | null
384
+ // .count() // number
385
+ // .groupBy("field") // GroupResult<T>[]
386
+ ```
387
+
388
+ Start from any class:
389
+
390
+ ```ts
391
+ Query.from(Car).where("year", ">=", 2023).execute();
392
+ Query.from("Car").where("make", "=", "Toyota").first();
393
+ ```
394
+
395
+ ### Filter operators
396
+
397
+ `=`, `!=`, `<`, `>`, `<=`, `>=`, `contains`, `startsWith`, `in`, `search`
398
+
399
+ The `search` operator uses an in-memory **trigram (n-gram) index** built from the combined index sheet
400
+ (`idx_{ClassName}s`). When the target field is `@Indexed`, the query is optimised: candidates are narrowed via
401
+ the n-gram index first, then verified with a substring match. For non-indexed fields `search` falls back to a
402
+ simple case-insensitive `contains`.
403
+
404
+ ```ts
405
+ // Fast n-gram search on @Indexed field — uses idx_Cars
406
+ const hits = Car.where("model", "search", "320i").execute();
407
+
408
+ // Programmatic access via IndexStore
409
+ const ids = indexStore.searchCombined("idx_Cars", "model", "320i");
410
+ ```
411
+
412
+ ### Field types
413
+
414
+ `string`, `number`, `boolean`, `json`, `date`, `reference`
415
+
416
+ ## Testing
417
+
418
+ ```bash
419
+ npm test
420
+ ```
421
+
422
+ Runs **128 unit and benchmark tests** across 9 test suites using Jest + ts-jest with in-memory mock adapters:
423
+
424
+ | Suite | Tests | Description |
425
+ | -------------------------- | ----- | -------------------------------------------- |
426
+ | `record.test.ts` | 34 | ActiveRecord API (save, find, query, Query) |
427
+ | `query-engine.test.ts` | 21 | Filter, sort, paginate, group |
428
+ | `serialization.test.ts` | 14 | Row ↔ Entity conversion |
429
+ | `query.test.ts` | 11 | Fluent query API |
430
+ | `index-store.test.ts` | 11 | Secondary index CRUD |
431
+ | `cache.test.ts` | 8 | MemoryCache TTL behavior |
432
+ | `uuid.test.ts` | 2 | UUID generation |
433
+ | `parity-validator.test.ts` | 3 | Jest ↔ GAS runtime parity check |
434
+ | `benchmark.test.ts` | 4 | 1 000-record perf benchmark: Cars vs Workers |
435
+
436
+ ### Benchmark Tests (`benchmark.test.ts`)
437
+
438
+ Two benchmark suites exercise the complete Record API against **1 000 records**:
439
+
440
+ | Suite | Class | Table | Index sheet | Notes |
441
+ | ----------------- | -------- | ------------- | ------------- | ------------------------------------ |
442
+ | Cars benchmark | `Car` | `tbl_Cars` | `idx_Cars` | All fields decorated with `@Indexed` |
443
+ | Workers benchmark | `Worker` | `tbl_Workers` | _not created_ | No `@Indexed` fields |
444
+
445
+ Both suites emit progress logs to stdout, covering every Record API operation: `save()`, `count()`,
446
+ `findById()`, `find()`, `findOne()`, `where()`, `query()`, `select()`, `groupBy()`, `update via save()`,
447
+ `set()/get()`, `delete()`, `deleteAll()`, `Query.from()`, `toJSON()`.
448
+
449
+ A timing comparison is printed at the end:
450
+
451
+ ```
452
+ ════════════════════════════════════════
453
+ BENCHMARK SUMMARY
454
+ ════════════════════════════════════════
455
+ tbl_Cars (with @Indexed): 311 ms
456
+ tbl_Workers (no @Indexed): 28 ms
457
+ Difference: 283 ms
458
+ Faster suite: tbl_Workers (by 283 ms)
459
+ Note: in mock environment @Indexed adds write overhead (index sheet writes).
460
+ In real Google Sheets, @Indexed enables faster lookups (fewer API reads).
461
+ ════════════════════════════════════════
462
+ ```
463
+
464
+ > **Note**: In the in-memory mock environment `@Indexed` adds write overhead (each indexed field value is
465
+ > written to the `idx_Cars` sheet). In real Google Sheets, `@Indexed` trades additional write cost for faster
466
+ > read lookups — beneficial when reading large datasets frequently.
467
+
468
+ ### Jest ↔ GAS Runtime Parity (1:1)
469
+
470
+ Every Jest test has a matching handler in the GAS runtime parity suite. This ensures the library works
471
+ identically with real Google Sheets:
472
+
473
+ - `src/testing/ParityCatalog.ts` — canonical list of all Jest test cases
474
+ - `src/testing/RuntimeParity.ts` — runtime suite executing against real Sheets API
475
+ - `tests/parity-validator.test.ts` — fails when Jest and runtime cases diverge
476
+
477
+ Run locally (mock adapters):
478
+
479
+ ```bash
480
+ npm test
481
+ ```
482
+
483
+ Run in Google Apps Script (real Sheets API):
484
+
485
+ - `runTests()` — executes full runtime parity suite against the active spreadsheet
486
+ - `validateTests()` — validates mapping only (fast drift check)
487
+
488
+ ### GAS Runtime Benchmark
489
+
490
+ A runtime benchmark mirrors `tests/benchmark.test.ts` and runs against the real Sheets API:
491
+
492
+ - `src/testing/RuntimeBenchmark.ts` — benchmark runner for Cars + Workers (100 records each)
493
+
494
+ Run in Google Apps Script (real Sheets API):
495
+
496
+ - `runBenchmark()` — executes full Cars + Workers benchmark and logs a timing summary
497
+
498
+ ## Exposed GAS Functions
499
+
500
+ Only three callable functions are surfaced as GAS globals (visible in the Apps Script editor Run menu and
501
+ callable as triggers). Everything else is an internal implementation detail bundled into `Code.js`.
502
+
503
+ | Function | Signature | Purpose | When to use |
504
+ | --------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
505
+ | `runTests` | `runTests(): void` | Executes the full runtime parity suite (all ~146 test cases) against the active Google Spreadsheet. Logs per-test PASS/FAIL to `Logger` and cleans up all temporary sheets after the run. | After deploying a new version of SheetORM to GAS to verify the real Sheets API behaves identically to the Jest mock suite. |
506
+ | `validateTests` | `validateTests(): void` | Checks that every Jest test case has a matching GAS handler (and vice versa). Throws immediately if the two catalogs have drifted — no Sheets API calls made. | Quick sanity check during development; runs in milliseconds without touching any spreadsheet. |
507
+ | `runBenchmark` | `runBenchmark(): void` | Runs a write/read/query/delete cycle for Cars (with `@Indexed`) and Workers (without `@Indexed`) using 100 records each. Logs per-operation timings and a comparison summary to `Logger`. | Measuring real-world Sheets API performance and understanding the latency trade-off of `@Indexed` versus plain tables. |
508
+
509
+ ## CI
510
+
511
+ GitHub Actions workflow at `.github/workflows/ci.yml`:
512
+
513
+ 1. TypeScript type-check (`tsc --noEmit`)
514
+ 2. Unit tests (`npm test`)
515
+ 3. Production build (`npm run build`)
516
+ 4. Verify `Code.js` output exists
517
+
518
+ Matrix: Node 18, 20, 22.
519
+
520
+ ## Available Scripts
521
+
522
+ | Script | Description |
523
+ | ---------------- | -------------------------------------------- |
524
+ | `npm run build` | Clean + compile TypeScript + bundle via Vite |
525
+ | `npm test` | Run all Jest tests |
526
+ | `npm run lint` | Lint with ESLint |
527
+ | `npm run format` | Format with Prettier |
528
+ | `npm run login` | Authenticate with Google Apps Script (once) |
529
+ | `npm run push` | Build + push to GAS |
530
+ | `npm run deploy` | Build + push + create versioned deployment |
531
+
532
+ ## Sheet Layout
533
+
534
+ Each registered table occupies one sheet. Row 1 contains headers: `__id`, `__createdAt`, `__updatedAt`,
535
+ followed by schema-defined fields. Data starts at row 2.
536
+
537
+ Special sheets:
538
+
539
+ - `tbl_{ClassName}s` — Data sheet (e.g. `tbl_Cars` for class `Car`)
540
+ - `idx_{ClassName}s` — Combined secondary index sheet (e.g. `idx_Cars`). Created automatically when the class
541
+ has at least one `@Indexed` field. Columns: `[field, value, entityId]` — each row maps one indexed field
542
+ value to its owning record. Classes with no `@Indexed` fields do **not** get an index sheet.
543
+
544
+ ## Storage Adapters
545
+
546
+ SheetORM separates sheet I/O behind two interfaces defined in `src/core/types/ISheetAdapter.ts` and `src/core/types/ISpreadsheetAdapter.ts`:
547
+
548
+ - **`ISpreadsheetAdapter`** — spreadsheet-level operations: `getSheetByName`, `createSheet`, `deleteSheet`, `getSheetNames`
549
+ - **`ISheetAdapter`** — sheet-level operations: read (`getAllData`, `getHeaders`, `getRow`, …), write (`appendRows`, `writeRowsAt`, `updateRow`, …), `clear`, `flush`
550
+
551
+ Two production adapters are provided. Both implement the full interface; they differ only in how write calls reach Google Sheets.
552
+
553
+ ### `GoogleSpreadsheetAdapter` / `GoogleSheetAdapter`
554
+
555
+ **Location**: `src/storage/GoogleSpreadsheetAdapter.ts`, `src/storage/GoogleSheetAdapter.ts`
556
+
557
+ Uses the native GAS `SpreadsheetApp` / `Sheet` objects directly. This is the default adapter — no configuration required.
558
+
559
+ ```ts
560
+ import { GoogleSpreadsheetAdapter } from "./src/storage/GoogleSpreadsheetAdapter";
561
+ import { Registry } from "./src/core/Registry";
562
+
563
+ Registry.getInstance().configure({
564
+ adapter: new GoogleSpreadsheetAdapter(), // uses active spreadsheet
565
+ });
566
+ // … or pass an explicit spreadsheet:
567
+ Registry.getInstance().configure({
568
+ adapter: new GoogleSpreadsheetAdapter(SpreadsheetApp.openById("…")),
569
+ });
570
+ ```
571
+
572
+ | Property | Value |
573
+ | -------------------- | ------------------------------------------------------------------ |
574
+ | Write mechanism | `Range.setValues()` — GAS native API |
575
+ | Calls per `saveAll` | **2** — one `setValues` for entity sheet + one for index sheet |
576
+ | Flush required | No — writes are synchronous and immediately visible |
577
+ | Quota impact | Counts against SpreadsheetApp call budget (no daily UrlFetch quota)|
578
+ | Read operations | Native `Range.getValues()` |
579
+ | GAS V8 latency | ~300–500 ms per `setValues` call on large ranges |
580
+ | Best for | All production use; default choice |
581
+
582
+ ### `SheetsAPIv4SpreadsheetAdapter` / `SheetsAPIv4SheetAdapter`
583
+
584
+ **Location**: `src/storage/SheetsAPIv4SpreadsheetAdapter.ts`, `src/storage/SheetsAPIv4SheetAdapter.ts`
585
+
586
+ Buffers all write operations (`writeRowsAt`, `appendRows`) in memory as A1-notation `ValueRange` objects and sends them all in a **single HTTP `spreadsheets.values.batchUpdate` request** when `flushAllPending()` is called. Read operations still use the native GAS API (the ORM reads from in-memory cache after `saveAll`, so extra sheet reads are rare).
587
+
588
+ ```ts
589
+ import { SheetsAPIv4SpreadsheetAdapter } from "./src/storage/SheetsAPIv4SpreadsheetAdapter";
590
+ import { Registry } from "./src/core/Registry";
591
+
592
+ const v4 = new SheetsAPIv4SpreadsheetAdapter(); // uses active spreadsheet
593
+ Registry.getInstance().configure({ adapter: v4 });
594
+
595
+ MyRecord.saveAll(items); // buffers entity rows + index rows in memory
596
+ v4.flushAllPending(); // ONE HTTP batchUpdate — both sheets at once
597
+ ```
598
+
599
+ | Property | Value |
600
+ | -------------------- | -------------------------------------------------------------------------- |
601
+ | Write mechanism | `UrlFetchApp.fetch()` → Sheets REST API v4 `values:batchUpdate` |
602
+ | Calls per `saveAll` | **1** — entity sheet + index sheet rows combined in one HTTP request |
603
+ | Flush required | **Yes** — call `v4.flushAllPending()` after each `saveAll` (or batch) |
604
+ | Auth | `ScriptApp.getOAuthToken()` — reuses the existing `spreadsheets` scope |
605
+ | Quota impact | `UrlFetchApp` quota: 20 000 calls/day per script |
606
+ | Read operations | Native `Range.getValues()` — unchanged |
607
+ | GAS V8 latency | 1 HTTP round-trip instead of N `setValues()` calls |
608
+ | Best for | Benchmarking / high-volume batch imports where minimising API calls matters |
609
+
610
+ **Required OAuth scopes** (already set in `appsscript.json`):
611
+
612
+ ```json
613
+ "oauthScopes": [
614
+ "https://www.googleapis.com/auth/spreadsheets",
615
+ "https://www.googleapis.com/auth/script.external_request"
616
+ ]
617
+ ```
618
+
619
+ ### Adapter comparison
620
+
621
+ | Criterion | `GoogleSpreadsheetAdapter` | `SheetsAPIv4SpreadsheetAdapter` |
622
+ | --------------------------- | :------------------------: | :-----------------------------: |
623
+ | Setup complexity | None | Requires `flushAllPending()` call |
624
+ | Write batching | 2 calls per `saveAll` | 1 HTTP call per flush |
625
+ | Flush step needed | No | Yes |
626
+ | UrlFetch daily quota used | No | Yes (1 per flush) |
627
+ | Works without internet auth | Yes (native GAS) | No (needs `UrlFetchApp`) |
628
+ | In-band error visibility | Immediate on write | Deferred to flush |
629
+ | Recommended default | ✅ Yes | Only for bulk-write optimisation |
630
+
631
+ The `GoogleSpreadsheetAdapter` is the default and recommended choice for all production use. The `SheetsAPIv4SpreadsheetAdapter` is provided as an opt-in for scenarios where reducing the number of GAS API calls is the primary concern (e.g. large batch imports exceeding GAS execution-time limits).
632
+
633
+ ## Development Notes
634
+
635
+ ### Exposing Functions to GAS
636
+
637
+ `src/index.ts` now exports a single `GasEntrypoints` class. During bundling, the Vite GAS plugin maps its
638
+ static methods to GAS globals (`runTests`, `validateTests`, `runBenchmark`) and also emits matching stub
639
+ functions so that Apps Script still shows them in the Run menu.
640
+
641
+ ```ts
642
+ export class GasEntrypoints {
643
+ static runTests(): void {
644
+ RuntimeParity.run();
645
+ }
646
+
647
+ static validateTests(): void {
648
+ RuntimeParity.validate();
649
+ }
650
+
651
+ static runBenchmark(): void {
652
+ RuntimeBenchmark.run();
653
+ }
654
+ }
655
+ ```
656
+
657
+ This keeps the source aligned with the repository rule of one public export per file while preserving the same
658
+ three callable GAS entrypoints.
659
+
660
+ ### Circular Dependencies
661
+
662
+ Circular dependencies (files that depend on each other in a circular manner) can cause unexpected issues like
663
+ "X is not a function" or "X is not defined". If you are seeing these errors in your project and you know they
664
+ are wrong, try checking for circular dependencies using [`madge`](https://github.com/pahen/madge) (not
665
+ included in this template):
666
+
667
+ 1. Install `madge` globally with `npm i --global madge`.
668
+ 2. Check for circular dependencies with `madge src/index.ts --circular`.
669
+
670
+ ## License
671
+
672
+ GPL-2.0 — see [license.md](license.md).