@modelprofile.com/flexharness 11.0.1 → 11.1.1

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 (69) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/classes.codesandbox.d.ts +12 -4
  3. package/dist_ts/classes.codesandbox.js +119 -287
  4. package/dist_ts/classes.codesandbox.worker.d.ts +52 -0
  5. package/dist_ts/classes.codesandbox.worker.js +310 -0
  6. package/dist_ts/classes.flexharness.callbacks.d.ts +37 -1
  7. package/dist_ts/classes.flexharness.callbacks.js +162 -53
  8. package/dist_ts/classes.flexharness.core.d.ts +2 -1
  9. package/dist_ts/classes.flexharness.core.js +22 -18
  10. package/dist_ts/classes.flexharness.results.js +4 -4
  11. package/dist_ts/classes.flexharness.reversions.d.ts +7 -0
  12. package/dist_ts/classes.flexharness.reversions.js +40 -39
  13. package/dist_ts/classes.flexharness.runs.js +7 -3
  14. package/dist_ts/classes.flexharness.tooldispatch.d.ts +2 -1
  15. package/dist_ts/classes.flexharness.tooldispatch.js +27 -9
  16. package/dist_ts/classes.stores.js +15 -13
  17. package/dist_ts/errors.d.ts +5 -0
  18. package/dist_ts/errors.js +10 -1
  19. package/dist_ts/index.d.ts +2 -1
  20. package/dist_ts/index.js +3 -2
  21. package/dist_ts/interfaces.d.ts +31 -9
  22. package/dist_ts/interfaces.flexharness.d.ts +14 -0
  23. package/dist_ts/interfaces.js +3 -3
  24. package/dist_ts/plugins.d.ts +6 -4
  25. package/dist_ts/plugins.js +10 -11
  26. package/dist_ts/utils.harnessoptions.d.ts +3 -1
  27. package/dist_ts/utils.harnessoptions.js +15 -2
  28. package/dist_ts/utils.json.d.ts +9 -1
  29. package/dist_ts/utils.json.js +92 -33
  30. package/dist_ts/utils.storecodecs.d.ts +1 -1
  31. package/dist_ts/utils.storecodecs.js +4 -4
  32. package/dist_ts/utils.storerules.d.ts +3 -3
  33. package/dist_ts/utils.storerules.js +1 -14
  34. package/dist_ts_mcp/index.d.ts +3 -1
  35. package/dist_ts_mcp/index.js +17 -2
  36. package/dist_ts_stores_nosqldb/classes.nosqldbstores.d.ts +18 -6
  37. package/dist_ts_stores_nosqldb/classes.nosqldbstores.js +27 -16
  38. package/dist_ts_stores_nosqldb/classes.recordstore.d.ts +33 -3
  39. package/dist_ts_stores_nosqldb/classes.recordstore.js +78 -18
  40. package/dist_ts_stores_nosqldb/index.d.ts +2 -2
  41. package/dist_ts_stores_nosqldb/index.js +2 -2
  42. package/package.json +3 -3
  43. package/readme.md +8 -2
  44. package/ts/00_commitinfo_data.ts +1 -1
  45. package/ts/classes.codesandbox.ts +132 -276
  46. package/ts/classes.codesandbox.worker.ts +345 -0
  47. package/ts/classes.flexharness.callbacks.ts +207 -55
  48. package/ts/classes.flexharness.core.ts +22 -16
  49. package/ts/classes.flexharness.results.ts +3 -3
  50. package/ts/classes.flexharness.reversions.ts +43 -32
  51. package/ts/classes.flexharness.runs.ts +6 -2
  52. package/ts/classes.flexharness.tooldispatch.ts +23 -7
  53. package/ts/classes.stores.ts +21 -12
  54. package/ts/errors.ts +14 -0
  55. package/ts/index.ts +7 -1
  56. package/ts/interfaces.flexharness.ts +15 -0
  57. package/ts/interfaces.ts +32 -9
  58. package/ts/plugins.ts +9 -12
  59. package/ts/readme.md +8 -2
  60. package/ts/utils.harnessoptions.ts +22 -0
  61. package/ts/utils.json.ts +112 -35
  62. package/ts/utils.storecodecs.ts +3 -3
  63. package/ts/utils.storerules.ts +3 -24
  64. package/ts_mcp/index.ts +17 -1
  65. package/ts_mcp/readme.md +3 -1
  66. package/ts_stores_nosqldb/classes.nosqldbstores.ts +39 -15
  67. package/ts_stores_nosqldb/classes.recordstore.ts +105 -13
  68. package/ts_stores_nosqldb/index.ts +2 -0
  69. package/ts_stores_nosqldb/readme.md +10 -2
@@ -2,6 +2,7 @@ import * as plugins from './plugins.js';
2
2
  import {
3
3
  FlexHarnessStoreCommitUncertainError,
4
4
  FlexHarnessStoreFormatError,
5
+ FlexHarnessStorePurgedError,
5
6
  FlexHarnessValidationError,
6
7
  } from '../ts/errors.js';
7
8
 
@@ -40,6 +41,7 @@ export interface IFlexRecordMutation<TValue> {
40
41
 
41
42
  export const FLEX_RECORD_COLLECTION = 'flexharness_records';
42
43
  export const FLEX_CHUNK_COLLECTION = 'flexharness_chunks';
44
+ export const FLEX_NAMESPACE_COLLECTION = 'flexharness_namespaces';
43
45
 
44
46
  /**
45
47
  * UTF-16 code units a record holds inline, and the size of each chunk of a longer payload. A
@@ -89,12 +91,31 @@ export class FlexChunkDocument extends plugins.SmartDataDbDoc<FlexChunkDocument,
89
91
  public data!: string;
90
92
  }
91
93
 
94
+ /**
95
+ * One storage namespace. Every record creation in the namespace increments `creationCount` in
96
+ * its transaction, so a concurrent fenced purge, which sets `fenced`, conflicts with it. A fenced
97
+ * namespace accepts no new record.
98
+ */
99
+ export class FlexNamespaceDocument extends plugins.SmartDataDbDoc<FlexNamespaceDocument, FlexNamespaceDocument> {
100
+ public namespaceKey!: string;
101
+ public fenced!: boolean;
102
+ public creationCount!: number;
103
+ }
104
+
92
105
  type TRecordModel = typeof FlexRecordDocument;
93
106
  type TChunkModel = typeof FlexChunkDocument;
107
+ type TNamespaceModel = typeof FlexNamespaceDocument;
94
108
 
95
- function bindModels(db: plugins.SmartdataDb): { records: TRecordModel; chunks: TChunkModel } {
109
+ interface IBoundModels {
110
+ records: TRecordModel;
111
+ chunks: TChunkModel;
112
+ namespaces: TNamespaceModel;
113
+ }
114
+
115
+ function bindModels(db: plugins.SmartdataDb): IBoundModels {
96
116
  class BoundRecord extends FlexRecordDocument {}
97
117
  class BoundChunk extends FlexChunkDocument {}
118
+ class BoundNamespace extends FlexNamespaceDocument {}
98
119
  plugins.defineCollectionModel(BoundRecord, db, {
99
120
  collectionName: FLEX_RECORD_COLLECTION,
100
121
  persistedFields: [
@@ -142,7 +163,16 @@ function bindModels(db: plugins.SmartdataDb): { records: TRecordModel; chunks: T
142
163
  { name: 'flexharness_chunk_namespace', key: { namespaceKey: 1, domain: 1 } },
143
164
  ],
144
165
  });
145
- return { records: BoundRecord, chunks: BoundChunk };
166
+ plugins.defineCollectionModel(BoundNamespace, db, {
167
+ collectionName: FLEX_NAMESPACE_COLLECTION,
168
+ persistedFields: ['namespaceKey', 'fenced', 'creationCount'],
169
+ numericFields: ['creationCount'],
170
+ identityFields: ['namespaceKey'],
171
+ indexes: [
172
+ { name: 'flexharness_namespace_key', key: { namespaceKey: 1 }, options: { unique: true } },
173
+ ],
174
+ });
175
+ return { records: BoundRecord, chunks: BoundChunk, namespaces: BoundNamespace };
146
176
  }
147
177
 
148
178
  interface IResolvedAddress {
@@ -208,13 +238,15 @@ export interface IFlexRecordStoreOptions {
208
238
  * Compare-and-swap records on `@lossless.org/client/nosqldb`. A write reads the head, decides,
209
239
  * writes any chunks, and swaps the head only while its `writeToken` is unchanged. Chunks of a
210
240
  * replaced or abandoned payload are swept in a transaction that also touches the head, so a
211
- * concurrent deletion or writer can never lose chunks its head references.
241
+ * concurrent deletion or writer can never lose chunks its head references. A head is created in
242
+ * a transaction that checks its namespace document, so a fenced namespace gains no record.
212
243
  */
213
244
  export class FlexRecordStore {
214
245
  private readonly db: plugins.SmartdataDb;
215
246
  private readonly timeoutMs: number;
216
247
  private readonly records: TRecordModel;
217
248
  private readonly chunks: TChunkModel;
249
+ private readonly namespaces: TNamespaceModel;
218
250
  private preparation?: Promise<void>;
219
251
 
220
252
  constructor(options: IFlexRecordStoreOptions) {
@@ -224,10 +256,10 @@ export class FlexRecordStore {
224
256
  }
225
257
  this.db = options.db;
226
258
  this.timeoutMs = timeoutMs;
227
- ({ records: this.records, chunks: this.chunks } = bindModels(options.db));
259
+ ({ records: this.records, chunks: this.chunks, namespaces: this.namespaces } = bindModels(options.db));
228
260
  }
229
261
 
230
- /** Creates or verifies both collections and their indexes once; a failed attempt is retried. */
262
+ /** Creates or verifies the collections and their indexes once; a failed attempt is retried. */
231
263
  public prepare(): Promise<void> {
232
264
  this.preparation ??= this.prepareCollections().catch((error: unknown) => {
233
265
  this.preparation = undefined;
@@ -251,6 +283,7 @@ export class FlexRecordStore {
251
283
  const options = { mode: 'ensureCompatible' as const, timeoutMs: this.timeoutMs };
252
284
  await this.records.prepareCollection(options);
253
285
  await this.chunks.prepareCollection(options);
286
+ await this.namespaces.prepareCollection(options);
254
287
  }
255
288
 
256
289
  private resolve(address: IFlexRecordAddress): IResolvedAddress {
@@ -318,26 +351,57 @@ export class FlexRecordStore {
318
351
  );
319
352
  }
320
353
 
321
- /** Deletes every record and chunk of a storage namespace, in one domain or in every domain. */
354
+ /**
355
+ * Deletes every record and chunk of a storage namespace in one domain, or in every domain
356
+ * together with its namespace document unless a fence holds it.
357
+ */
322
358
  public async purgeNamespace(storageKey: string, domain?: TFlexRecordDomain): Promise<void> {
323
359
  await this.prepare();
324
360
  const namespaceKey = digest(storageKey);
325
361
  await this.deleteInTransaction(
326
362
  `${FLEX_RECORD_COLLECTION}/${domain ?? 'all'}/namespace/${namespaceKey}`,
327
363
  domain ? { namespaceKey, domain } : { namespaceKey },
364
+ domain
365
+ ? undefined
366
+ : (transaction) => this.namespaces.atomicDeleteMany(
367
+ { namespaceKey, fenced: false },
368
+ { session: transaction },
369
+ ),
370
+ );
371
+ }
372
+
373
+ /**
374
+ * Fences a storage namespace and deletes every record and chunk of it in one transaction. The
375
+ * fence is permanent: every later record creation in the namespace is refused, and a creation
376
+ * that runs concurrently either commits first and is deleted, or conflicts and is refused.
377
+ */
378
+ public async fenceNamespace(storageKey: string): Promise<void> {
379
+ await this.prepare();
380
+ const namespaceKey = digest(storageKey);
381
+ await this.deleteInTransaction(
382
+ `${FLEX_RECORD_COLLECTION}/all/namespace/${namespaceKey}`,
383
+ { namespaceKey },
384
+ (transaction) => this.namespaces.atomicUpdate(
385
+ { namespaceKey },
386
+ { $set: { fenced: true }, $setOnInsert: { creationCount: 0 } },
387
+ { upsert: true, session: transaction },
388
+ ),
328
389
  );
329
390
  }
330
391
 
392
+ /** Deletes the selected records and chunks, after `namespace` changes the namespace document. */
331
393
  private async deleteInTransaction(
332
394
  path: string,
333
395
  selector:
334
396
  | { recordId: string; domain: TFlexRecordDomain }
335
397
  | { ownerKey: string; domain: { $in: TFlexRecordDomain[] } }
336
398
  | { namespaceKey: string; domain?: TFlexRecordDomain },
399
+ namespace?: (transaction: plugins.SmartdataSession) => Promise<unknown>,
337
400
  ): Promise<void> {
338
401
  const session = this.db.createSession();
339
402
  try {
340
403
  await session.withTransaction(async (transaction) => {
404
+ await namespace?.(transaction);
341
405
  await this.records.atomicDeleteMany(selector, { session: transaction });
342
406
  await this.chunks.atomicDeleteMany(selector, { session: transaction });
343
407
  }, { timeoutMS: this.timeoutMs });
@@ -480,11 +544,9 @@ export class FlexRecordStore {
480
544
  head.payload = text;
481
545
  head.chunked = false;
482
546
  try {
483
- await this.records.insert(head, { timeoutMS: this.timeoutMs });
484
- return true;
547
+ return await this.create(resolved, head);
485
548
  } catch (error) {
486
- if (isUniqueConflict(error)) return false;
487
- if (isDefiniteRejection(error)) throw error;
549
+ if (plugins.SmartdataSession.getCommitOutcome(error) === 'not_committed') throw error;
488
550
  throw new FlexHarnessStoreCommitUncertainError(resolved.path, 'write', { cause: error });
489
551
  }
490
552
  }
@@ -497,12 +559,42 @@ export class FlexRecordStore {
497
559
  head.revision = 0;
498
560
  head.payload = null;
499
561
  head.chunked = true;
562
+ return await this.create(resolved, head) ? head : undefined;
563
+ }
564
+
565
+ /**
566
+ * Inserts a new head unless a fence holds its namespace. The transaction also increments the
567
+ * namespace document, so it conflicts with a concurrent fenced purge: whichever commits second
568
+ * retries on a snapshot that holds the other. Returns false when another writer created the
569
+ * record first.
570
+ */
571
+ private async create(resolved: IResolvedAddress, head: FlexRecordDocument): Promise<boolean> {
572
+ const session = this.db.createSession();
500
573
  try {
501
- await this.records.insert(head, { timeoutMS: this.timeoutMs });
502
- return head;
574
+ await session.withTransaction(async (transaction) => {
575
+ const namespace = await this.namespaces.getInstance(
576
+ { namespaceKey: resolved.namespaceKey },
577
+ { session: transaction },
578
+ );
579
+ if (namespace?.fenced) throw new FlexHarnessStorePurgedError(resolved.path);
580
+ await this.namespaces.atomicUpdate(
581
+ { namespaceKey: resolved.namespaceKey },
582
+ { $inc: { creationCount: 1 }, $setOnInsert: { fenced: false } },
583
+ { upsert: true, session: transaction },
584
+ );
585
+ await this.records.insert(head, { session: transaction });
586
+ }, { timeoutMS: this.timeoutMs });
587
+ return true;
503
588
  } catch (error) {
504
- if (isUniqueConflict(error)) return undefined;
589
+ if (
590
+ isUniqueConflict(error) &&
591
+ plugins.SmartdataSession.getCommitOutcome(error) === 'not_committed'
592
+ ) {
593
+ return false;
594
+ }
505
595
  throw error;
596
+ } finally {
597
+ await session.close();
506
598
  }
507
599
  }
508
600
 
@@ -1,9 +1,11 @@
1
1
  export {
2
2
  NoSqlFlexHarnessStores,
3
+ type INoSqlFlexHarnessPurgeOptions,
3
4
  type INoSqlFlexHarnessStoresOptions,
4
5
  } from './classes.nosqldbstores.js';
5
6
  export {
6
7
  FLEX_CHUNK_COLLECTION,
8
+ FLEX_NAMESPACE_COLLECTION,
7
9
  FLEX_RECORD_CHUNK_CODE_UNITS,
8
10
  FLEX_RECORD_COLLECTION,
9
11
  } from './classes.recordstore.js';
@@ -19,17 +19,25 @@ const harness = new FlexHarness({ scopeResolver, modelResolver, stores });
19
19
 
20
20
  `NoSqlFlexHarnessStores` implements every `IFlexHarnessStores` domain: scopes, projections with undo and redo history, remembered permissions, project management, Agent events with their archives, and background jobs. Several processes may open the same database; every save is a compare-and-swap on the stored record, so a stale writer gets the domain's conflict error instead of overwriting a newer snapshot.
21
21
 
22
- The database is the application's. The stores never close it, and they need NoSQLDB or a MongoDB deployment with transactions. `prepare()` creates or verifies the `flexharness_records` and `flexharness_chunks` collections and their indexes; every store operation awaits the same preparation, so calling it at startup only moves that work and its failure forward. `operationTimeoutMs` (1 to 120000, default 30000) bounds every database call and transaction.
22
+ The database is the application's. The stores never close it, and they need NoSQLDB or a MongoDB deployment with transactions. `prepare()` creates or verifies the `flexharness_records`, `flexharness_chunks` and `flexharness_namespaces` collections and their indexes; every store operation awaits the same preparation, so calling it at startup only moves that work and its failure forward. `operationTimeoutMs` (1 to 120000, default 30000) bounds every database call and transaction.
23
23
 
24
24
  A snapshot of up to `FLEX_RECORD_CHUNK_CODE_UNITS` UTF-16 code units of JSON is stored inside its record. A longer one is split into chunks of that size, each below 3 MiB of UTF-8, so a snapshot may exceed the 16 MiB document limit of NoSQLDB and MongoDB. Chunks are written before the record swaps to them. The chunks a record no longer references are removed by a later write of that record, in a transaction that also touches the record, and deleting a session or purging a namespace removes its records and chunks in one transaction. A writer that crashes between writing its chunks and swapping the record leaves only unreferenced chunks, never a record that points at missing ones. They are written against the record's current write token, which a concurrent writer may still win with, so the write after the next one removes them, or the deletion of the session or namespace does.
25
25
 
26
- `stores.purgeNamespace(storageKey)` is the durable deletion of a scope: it removes every record and chunk of that storage namespace in every domain, including the scope snapshot and the project-management tombstones that deleting its sessions keeps, in one transaction. Deleting every session alone leaves those records, keyed by the storage key. Call it only after stopping new admission for every alias of the scope and awaiting `harness.retireScope()` on every harness that owns it:
26
+ `stores.purgeNamespace(storageKey)` is the durable deletion of a scope: it removes every record and chunk of that storage namespace in every domain, including the scope snapshot and the project-management tombstones that deleting its sessions keeps, in one transaction. Deleting every session alone leaves those records, keyed by the storage key. Stop new admission for every alias of the scope first. A plain purge must then wait until `harness.retireScope()` has returned on every harness that owns the scope: a harness that still holds it can write records back, because the purge removed the revisions its writes would conflict with.
27
27
 
28
28
  ```typescript
29
29
  await harness.retireScope(scopeId);
30
30
  await stores.purgeNamespace(storageKey);
31
31
  ```
32
32
 
33
+ When a harness may still hold the scope, such as one on another server, purge with `{ fence: true }`. The same transaction fences the namespace in `flexharness_namespaces`: every later record creation in it, from any process, is refused with `FlexHarnessStorePurgedError`, and a write that extends a purged record conflicts, so nothing a stale harness writes lands. A creation that races the purge either commits first and is removed with the rest, or is refused. The fence is permanent, so the storage key is never usable again, and a later purge without the fence does not lift it. FlexHarness reports a refused write of a session operation through `externalErrorProjector`, like every other store failure there.
34
+
35
+ ```typescript
36
+ await stores.purgeNamespace(storageKey, { fence: true });
37
+ ```
38
+
39
+ Every record creation is a transaction that checks the namespace's fence document and writes to it, so concurrent creations in one namespace contend on that document and the driver retries the ones that conflict.
40
+
33
41
  A record swap that loses to a concurrent writer is decided again against the newer record, up to eight times, with a bounded exponential backoff between attempts; a writer that still loses gets the domain's conflict error.
34
42
 
35
43
  A save that may have reached the database without a confirmation, such as a timed-out or interrupted record write or a deletion transaction with an unknown commit outcome, rejects with `FlexHarnessStoreCommitUncertainError` (operation `write` or `delete`). FlexHarness then fences the namespace, as it does for the JSON file stores.