edinburgh 0.4.4 → 0.4.6

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 (61) hide show
  1. package/README.md +119 -150
  2. package/build/src/edinburgh.js +4 -2
  3. package/build/src/edinburgh.js.map +1 -1
  4. package/build/src/indexes.d.ts +2 -3
  5. package/build/src/indexes.js +3 -5
  6. package/build/src/indexes.js.map +1 -1
  7. package/build/src/migrate-cli.d.ts +1 -16
  8. package/build/src/migrate-cli.js +56 -42
  9. package/build/src/migrate-cli.js.map +1 -1
  10. package/build/src/migrate.d.ts +15 -11
  11. package/build/src/migrate.js +53 -39
  12. package/build/src/migrate.js.map +1 -1
  13. package/build/src/models.d.ts +8 -2
  14. package/build/src/models.js +59 -51
  15. package/build/src/models.js.map +1 -1
  16. package/build/src/types.d.ts +2 -1
  17. package/package.json +6 -4
  18. package/skill/BaseIndex.md +16 -0
  19. package/skill/BaseIndex_batchProcess.md +10 -0
  20. package/skill/BaseIndex_find.md +7 -0
  21. package/skill/DatabaseError.md +9 -0
  22. package/skill/Model.md +22 -0
  23. package/skill/Model_delete.md +14 -0
  24. package/skill/Model_findAll.md +12 -0
  25. package/skill/Model_getPrimaryKeyHash.md +5 -0
  26. package/skill/Model_isValid.md +14 -0
  27. package/skill/Model_migrate.md +34 -0
  28. package/skill/Model_preCommit.md +28 -0
  29. package/skill/Model_preventPersist.md +15 -0
  30. package/skill/Model_replaceInto.md +16 -0
  31. package/skill/Model_validate.md +21 -0
  32. package/skill/PrimaryIndex.md +8 -0
  33. package/skill/PrimaryIndex_get.md +17 -0
  34. package/skill/PrimaryIndex_getLazy.md +13 -0
  35. package/skill/SKILL.md +96 -714
  36. package/skill/SecondaryIndex.md +9 -0
  37. package/skill/UniqueIndex.md +9 -0
  38. package/skill/UniqueIndex_get.md +17 -0
  39. package/skill/array.md +23 -0
  40. package/skill/dump.md +8 -0
  41. package/skill/field.md +29 -0
  42. package/skill/index.md +32 -0
  43. package/skill/init.md +17 -0
  44. package/skill/link.md +27 -0
  45. package/skill/literal.md +22 -0
  46. package/skill/opt.md +22 -0
  47. package/skill/or.md +22 -0
  48. package/skill/primary.md +26 -0
  49. package/skill/record.md +21 -0
  50. package/skill/registerModel.md +26 -0
  51. package/skill/runMigration.md +10 -0
  52. package/skill/set.md +23 -0
  53. package/skill/setMaxRetryCount.md +10 -0
  54. package/skill/setOnSaveCallback.md +12 -0
  55. package/skill/transact.md +49 -0
  56. package/skill/unique.md +32 -0
  57. package/src/edinburgh.ts +4 -2
  58. package/src/indexes.ts +3 -6
  59. package/src/migrate-cli.ts +44 -46
  60. package/src/migrate.ts +64 -46
  61. package/src/models.ts +59 -51
package/src/migrate.ts CHANGED
@@ -11,12 +11,14 @@ const INDEX_ID_PREFIX = -2;
11
11
  export interface MigrationOptions {
12
12
  /** Limit migration to specific table names. */
13
13
  tables?: string[];
14
- /** Whether to convert old primary indices for known tables (default: true). */
15
- convertOldPrimaries?: boolean;
16
- /** Whether to delete orphaned secondary/unique indices (default: true). */
17
- deleteOrphanedIndexes?: boolean;
18
- /** Whether to upgrade rows to the latest version (default: true). */
19
- upgradeVersions?: boolean;
14
+ /** Populate secondary indexes for rows at old schema versions (default: true). */
15
+ populateSecondaries?: boolean;
16
+ /** Convert old primary indices when primary key fields changed (default: true). */
17
+ migratePrimaries?: boolean;
18
+ /** Rewrite all row data to the latest schema version (default: false). */
19
+ rewriteData?: boolean;
20
+ /** Delete orphaned secondary/unique index entries (default: true). */
21
+ removeOrphans?: boolean;
20
22
  /** Progress callback. */
21
23
  onProgress?: (info: ProgressInfo) => void;
22
24
  }
@@ -29,14 +31,16 @@ export interface ProgressInfo {
29
31
  }
30
32
 
31
33
  export interface MigrationResult {
32
- /** Per-table stats for row upgrades. */
34
+ /** Per-table counts of secondary index entries populated. */
33
35
  secondaries: Record<string, number>;
34
- /** Per-table stats for old primary conversions. */
36
+ /** Per-table counts of old primary rows migrated. */
35
37
  primaries: Record<string, number>;
36
38
  /** Per-table conversion failure counts by reason. */
37
39
  conversionFailures: Record<string, Record<string, number>>;
40
+ /** Per-table counts of rows rewritten to latest version. */
41
+ rewritten: Record<string, number>;
38
42
  /** Number of orphaned index entries deleted. */
39
- orphaned: number;
43
+ orphans: number;
40
44
  }
41
45
 
42
46
  interface IndexDef {
@@ -91,23 +95,25 @@ async function forEachRow(
91
95
  }
92
96
 
93
97
  /**
94
- * Run database migration: upgrade all rows to the latest schema version,
95
- * convert old primary indices, and clean up orphaned secondary indices.
98
+ * Run database migration: populate secondary indexes for old-version rows,
99
+ * convert old primary indices, rewrite row data, and clean up orphaned indices.
96
100
  */
97
101
  export async function runMigration(options: MigrationOptions = {}): Promise<MigrationResult> {
98
102
  // Ensure any pending model/index inits are completed before building index maps
99
103
  await transact(() => {});
100
104
 
101
- const convertOldPrimaries = options.convertOldPrimaries ?? true;
102
- const deleteOrphanedIndexes = options.deleteOrphanedIndexes ?? true;
103
- const upgradeVersions = options.upgradeVersions ?? true;
105
+ const populateSecondaries = options.populateSecondaries ?? true;
106
+ const migratePrimaries = options.migratePrimaries ?? true;
107
+ const rewriteData = options.rewriteData ?? false;
108
+ const removeOrphans = options.removeOrphans ?? true;
104
109
  const onProgress = options.onProgress;
105
110
 
106
111
  const result: MigrationResult = {
107
112
  secondaries: {},
108
113
  primaries: {},
109
114
  conversionFailures: {},
110
- orphaned: 0,
115
+ rewritten: {},
116
+ orphans: 0,
111
117
  };
112
118
 
113
119
  // Build maps of known index IDs
@@ -147,10 +153,11 @@ export async function runMigration(options: MigrationOptions = {}): Promise<Migr
147
153
  allIndexDefs.push({ id, tableName, typeName, fieldNames, fieldTypes });
148
154
  });
149
155
 
150
- // Phase 1: Upgrade existing rows to latest version
151
- if (upgradeVersions) {
156
+ // Phase 1: Populate secondary indexes and/or rewrite row data
157
+ if (populateSecondaries || rewriteData) {
152
158
  for (const [indexId, { model, primary }] of primaryByIndexId) {
153
- let upgraded = 0;
159
+ let secondaryCount = 0;
160
+ let rewrittenCount = 0;
154
161
  const migrateFn = (model as any).migrate as ((record: Record<string, any>) => void) | undefined;
155
162
  const secondaries = model._secondaries || [];
156
163
 
@@ -176,45 +183,56 @@ export async function runMigration(options: MigrationOptions = {}): Promise<Migr
176
183
  const preMigrate = migrateFn ? structuredClone(record) : undefined;
177
184
  if (migrateFn) migrateFn(record);
178
185
 
179
- // Handle secondaries (primary is left as-is for lazy migration on read)
180
- for (const sec of secondaries) {
181
- if (!versionInfo.secondaryKeys.has(sec._signature!)) {
182
- // New secondary, write entry
183
- sec._write(txn, keyBuf, record as any);
184
- upgraded++;
185
- } else if (preMigrate) {
186
- if (sec._computeFn) {
187
- // Computed indexes: compare serialized keys to avoid unnecessary re-indexing
188
- const oldKeyBytes = sec._serializeKeyFields(preMigrate).toUint8Array();
189
- const newKeyBytes = sec._serializeKeyFields(record).toUint8Array();
190
- if (!bytesEqual(oldKeyBytes, newKeyBytes)) {
191
- sec._delete(txn, keyBuf, preMigrate as any);
192
- sec._write(txn, keyBuf, record as any);
193
- upgraded++;
194
- }
195
- } else {
196
- // Existing secondary, update if migrate changed any of its fields
197
- for (const [field, type] of sec._fieldTypes.entries()) {
198
- if (!type.equals(preMigrate[field], record[field])) {
186
+ // Populate/update secondary indexes
187
+ if (populateSecondaries) {
188
+ for (const sec of secondaries) {
189
+ if (!versionInfo.secondaryKeys.has(sec._signature!)) {
190
+ // New secondary, write entry
191
+ sec._write(txn, keyBuf, record as any);
192
+ secondaryCount++;
193
+ } else if (preMigrate) {
194
+ if (sec._computeFn) {
195
+ // Computed indexes: compare serialized keys to avoid unnecessary re-indexing
196
+ const oldKeyBytes = sec._serializeKeyFields(preMigrate).toUint8Array();
197
+ const newKeyBytes = sec._serializeKeyFields(record).toUint8Array();
198
+ if (!bytesEqual(oldKeyBytes, newKeyBytes)) {
199
199
  sec._delete(txn, keyBuf, preMigrate as any);
200
200
  sec._write(txn, keyBuf, record as any);
201
- upgraded++;
202
- break;
201
+ secondaryCount++;
202
+ }
203
+ } else {
204
+ // Existing secondary, update if migrate changed any of its fields
205
+ for (const [field, type] of sec._fieldTypes.entries()) {
206
+ if (!type.equals(preMigrate[field], record[field])) {
207
+ sec._delete(txn, keyBuf, preMigrate as any);
208
+ sec._write(txn, keyBuf, record as any);
209
+ secondaryCount++;
210
+ break;
211
+ }
203
212
  }
204
213
  }
205
214
  }
206
215
  }
207
216
  }
208
217
 
218
+ // Rewrite primary row data to current version
219
+ if (rewriteData) {
220
+ primary._write(txn, keyBuf, record);
221
+ rewrittenCount++;
222
+ }
209
223
  });
210
224
 
211
- onProgress?.({ phase: 'secondaries', processed: upgraded, table: model.tableName });
212
- if (upgraded > 0) result.secondaries[model.tableName] = upgraded;
225
+ onProgress?.({ phase: 'secondaries', processed: secondaryCount, table: model.tableName });
226
+ if (secondaryCount > 0) result.secondaries[model.tableName] = secondaryCount;
227
+ if (rewrittenCount > 0) {
228
+ onProgress?.({ phase: 'rewritten', processed: rewrittenCount, table: model.tableName });
229
+ result.rewritten[model.tableName] = rewrittenCount;
230
+ }
213
231
  }
214
232
  }
215
233
 
216
234
  // Phase 2: Convert old primary indices with known table names
217
- if (convertOldPrimaries) {
235
+ if (migratePrimaries) {
218
236
  for (const oldDef of allIndexDefs) {
219
237
  if (oldDef.typeName !== 'primary') continue;
220
238
  if (knownIndexIds.has(oldDef.id)) continue; // Known index, skip
@@ -266,14 +284,14 @@ export async function runMigration(options: MigrationOptions = {}): Promise<Migr
266
284
  }
267
285
 
268
286
  // Phase 3: Delete orphaned secondary/unique index entries
269
- if (deleteOrphanedIndexes) {
287
+ if (removeOrphans) {
270
288
  for (const def of allIndexDefs) {
271
289
  if (knownIndexIds.has(def.id) || def.typeName === 'primary') continue;
272
290
  await forEachRow(def.id, (txn, keyBuf) => {
273
291
  dbDel(txn.id, keyBuf);
274
- result.orphaned++;
292
+ result.orphans++;
275
293
  });
276
- onProgress?.({ phase: 'orphaned', processed: result.orphaned });
294
+ onProgress?.({ phase: 'orphans', processed: result.orphans });
277
295
  }
278
296
  }
279
297
 
package/src/models.ts CHANGED
@@ -166,7 +166,7 @@ export interface Model<SUB> {
166
166
  *
167
167
  * Models represent database entities with typed fields, automatic serialization,
168
168
  * change tracking, and relationship management. All model classes should extend
169
- * this base class and be decorated with `@registerModel`.
169
+ * this base class and be decorated with `@E.registerModel`.
170
170
  *
171
171
  * ### Schema Evolution
172
172
  *
@@ -283,7 +283,7 @@ export abstract class Model<SUB> {
283
283
  // This constructor will only be called once, from `initModels`. All other instances will
284
284
  // be created by the 'fake' constructor. The typing for `initial` *is* important though.
285
285
  if (initial as any === INIT_INSTANCE_SYMBOL) return;
286
- throw new DatabaseError("The model needs a @registerModel decorator", 'INIT_ERROR');
286
+ throw new DatabaseError("The model needs a @E.registerModel decorator", 'INIT_ERROR');
287
287
  }
288
288
 
289
289
  /**
@@ -313,70 +313,78 @@ export abstract class Model<SUB> {
313
313
  */
314
314
  preCommit?(): void;
315
315
 
316
- static async _delayedInit(cleared?: boolean): Promise<void> {
316
+ /**
317
+ * Transform the model's `E.field` properties into the appropriate JavaScript properties. Normally this is done
318
+ * automatically when using `transact()`, but in case you need to access `Model.fields` directly before the first
319
+ * transaction, you can call this method manually.
320
+ */
321
+ static initFields(reset?: boolean): void {
317
322
  const MockModel = getMockModel(this);
318
323
 
319
- if (cleared) {
324
+ if (reset) {
320
325
  MockModel._primary._indexId = undefined;
321
326
  MockModel._primary._versions.clear();
322
327
  for (const sec of MockModel._secondaries || []) sec._indexId = undefined;
323
328
  }
324
329
 
325
- if (!MockModel.fields) {
326
- // First-time init: gather field configs from a temporary instance of the original class.
327
- const OrgModel = (MockModel as any)._original || this;
328
- const instance = new (OrgModel as any)(INIT_INSTANCE_SYMBOL);
330
+ if (MockModel.fields) return;
329
331
 
330
- // If no primary key exists, create one using 'id' field
331
- if (!MockModel._primary) {
332
- if (!instance.id) {
333
- instance.id = { type: identifier };
334
- }
335
- // @ts-ignore-next-line - `id` is not part of the type, but the user probably shouldn't touch it anyhow
336
- new PrimaryIndex(MockModel, ['id']);
332
+ // First-time init: gather field configs from a temporary instance of the original class.
333
+ const OrgModel = (MockModel as any)._original || this;
334
+ const instance = new (OrgModel as any)(INIT_INSTANCE_SYMBOL);
335
+
336
+ // If no primary key exists, create one using 'id' field
337
+ if (!MockModel._primary) {
338
+ if (!instance.id) {
339
+ instance.id = { type: identifier };
337
340
  }
341
+ // @ts-ignore-next-line - `id` is not part of the type, but the user probably shouldn't touch it anyhow
342
+ new PrimaryIndex(MockModel, ['id']);
343
+ }
338
344
 
339
- MockModel.fields = {};
340
- for (const key in instance) {
341
- const value = instance[key] as FieldConfig<unknown>;
342
- // Check if this property contains field metadata
343
- if (value && value.type instanceof TypeWrapper) {
344
- // Set the configuration on the constructor's `fields` property
345
- MockModel.fields[key] = value;
346
-
347
- // Set default value on the prototype
348
- const defObj = value.default===undefined ? value.type : value;
349
- const def = defObj.default;
350
- if (typeof def === 'function') {
351
- // The default is a function. We'll define a getter on the property in the model prototype,
352
- // and once it is read, we'll run the function and set the value as a plain old property
353
- // on the instance object.
354
- Object.defineProperty(MockModel.prototype, key, {
355
- get() {
356
- // This will call set(), which will define the property on the instance.
357
- return (this[key] = def.call(defObj, this));
358
- },
359
- set(val: any) {
360
- Object.defineProperty(this, key, {
361
- value: val,
362
- configurable: true,
363
- writable: true,
364
- enumerable: true,
365
- })
366
- },
367
- configurable: true,
368
- });
369
- } else if (def !== undefined) {
370
- (MockModel.prototype as any)[key] = def;
371
- }
345
+ MockModel.fields = {};
346
+ for (const key in instance) {
347
+ const value = instance[key] as FieldConfig<unknown>;
348
+ // Check if this property contains field metadata
349
+ if (value && value.type instanceof TypeWrapper) {
350
+ // Set the configuration on the constructor's `fields` property
351
+ MockModel.fields[key] = value;
352
+
353
+ // Set default value on the prototype
354
+ const defObj = value.default===undefined ? value.type : value;
355
+ const def = defObj.default;
356
+ if (typeof def === 'function') {
357
+ // The default is a function. We'll define a getter on the property in the model prototype,
358
+ // and once it is read, we'll run the function and set the value as a plain old property
359
+ // on the instance object.
360
+ Object.defineProperty(MockModel.prototype, key, {
361
+ get() {
362
+ // This will call set(), which will define the property on the instance.
363
+ return (this[key] = def.call(defObj, this));
364
+ },
365
+ set(val: any) {
366
+ Object.defineProperty(this, key, {
367
+ value: val,
368
+ configurable: true,
369
+ writable: true,
370
+ enumerable: true,
371
+ })
372
+ },
373
+ configurable: true,
374
+ });
375
+ } else if (def !== undefined) {
376
+ (MockModel.prototype as any)[key] = def;
372
377
  }
373
378
  }
379
+ }
374
380
 
375
- if (logLevel >= 1) {
376
- console.log(`[edinburgh] Registered model ${MockModel.tableName} with fields: ${Object.keys(MockModel.fields).join(' ')}`);
377
- }
381
+ if (logLevel >= 1) {
382
+ console.log(`[edinburgh] Registered model ${MockModel.tableName} with fields: ${Object.keys(MockModel.fields).join(' ')}`);
378
383
  }
384
+ }
379
385
 
386
+ static async _loadCreateIndexes(): Promise<void> {
387
+ const MockModel = getMockModel(this);
380
388
  // Always run index inits (idempotent, skip if already initialized)
381
389
  await MockModel._primary._delayedInit();
382
390
  for (const sec of MockModel._secondaries || []) await sec._delayedInit();