@workbench-kit/field-remap 0.0.2-prototype.0.2.32 → 0.0.2-prototype.0.2.34

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/README.md CHANGED
@@ -95,6 +95,7 @@ import {
95
95
  FieldRemapFlowMapper,
96
96
  FieldRemapPanel,
97
97
  createJsonataValueTransform,
98
+ type FieldRemapPreviewState,
98
99
  } from '@workbench-kit/shell-react/field-remap';
99
100
  import '@workbench-kit/shell-react/field-remap/view.css';
100
101
 
@@ -111,12 +112,63 @@ transforms.register(createJsonataValueTransform());
111
112
 
112
113
  // Evaluate transform-bearing edges without a FieldRemapDocument:
113
114
  // await convertMappedInputs({ sources, targets, edges, inputs: { source: bag }, transforms })
115
+
116
+ // Direct Flow embeds stay presentation-only. Inject a host-precomputed runtime snapshot:
117
+ const preview: FieldRemapPreviewState = {
118
+ status: 'ready',
119
+ result: await convertMappedInputs({
120
+ sources,
121
+ targets,
122
+ edges,
123
+ inputs: { source: bag },
124
+ transforms,
125
+ }),
126
+ };
127
+ // <FieldRemapFlowMapper ... preview={preview} />
114
128
  ```
115
129
 
116
130
  Prefer `convertMappedInputs` when the host catalog stores `MappingEdge[]` (+ optional
117
131
  `operators[]`) separately from kit document JSON. Prefer `convertToShape` when you already
118
132
  build `defineConversion` / `defineDataShape` registries yourself.
119
133
 
134
+ ### Runtime preview ownership
135
+
136
+ `FieldRemapPanel` owns one abortable preview execution controller. Its legacy output pane
137
+ and optional `showFlowPreview` rail consume the same immutable result. Direct
138
+ `FieldRemapFlowMapper` embeds never execute mappings; hosts inject `preview` and may use
139
+ `showPreview={false}` to unmount the rail and its splitter track.
140
+
141
+ Selection is a read-only projection over the injected result and does not re-evaluate:
142
+
143
+ - no selection and operator selection show final document output after operators;
144
+ - edge selection shows the edge-local `ConvertToShapeResult.slots` value before an
145
+ operator can overwrite that target;
146
+ - transform-step selection shows the final edge value, not an intermediate step value;
147
+ - operator-local intermediate values are not available;
148
+ - draft and stale selections are stable unsupported states.
149
+
150
+ An unavailable `hidden` / `no-sample` snapshot mounts no rail. Preview state is runtime-only
151
+ and is never written into `FieldRemapDocument`, history or persistence.
152
+
153
+ ### Projection protocol conformance
154
+
155
+ The package contains a private, backendless reference owner for the generic
156
+ `@workbench-kit/contracts` projection protocol. It serializes revision comparison,
157
+ edge/operator translation, validation, persistence and publication around one canonical
158
+ `{ edges, operators }` aggregate. Source/target shape revisions participate in the same
159
+ precondition cohort. Hidden-subset projections omit mappings and combine/split operators whose
160
+ operands are hidden while preserving them canonically; ambiguous partial edits fail closed.
161
+
162
+ The owner is intentionally not exported. Existing `FieldRemapPanel` and
163
+ `FieldRemapFlowMapper` callback props remain compatible, but independent edge/operator callbacks
164
+ are not described as atomic or revision-aware. Ownership remains layered as follows:
165
+
166
+ | Layer | Ownership |
167
+ | ------------------ | ----------------------------------------------------------------------------------------------------------- |
168
+ | Generic contracts | Descriptor, authority, snapshot, opaque revision, transaction and result envelopes |
169
+ | Field Remap domain | Canonical edges/operators, shape revision cohort, translation, validation, persistence and semantic history |
170
+ | React shell | Selection, drafts, filters, viewport, splitter/chrome and runtime preview presentation |
171
+
120
172
  Place-then-wire uses **ephemeral draft nodes** in the shell Flow UI: place a
121
173
  transform, wire source then target (or the reverse), and the draft finalizes into
122
174
  a `MappingEdge` with `transformIds: [id]`. Escape discards unfinished drafts.
@@ -268,7 +320,8 @@ host transforms (JSONata 2.x) resolve correctly.
268
320
 
269
321
  Pass `signal` on `convertToShape` (or `TransformContext.signal`) to cancel stale previews.
270
322
  Aborted runs reject with `AbortError` and stop further edges / chain steps. The shell Field Remap
271
- panel wires an `AbortController` to effect cleanup.
323
+ panel controller also uses a private generation so late aborted/disposed results cannot replace
324
+ the latest snapshot.
272
325
 
273
326
  Host JSONata transforms in `@workbench-kit/shell-react` are fail-closed and bounded by default
274
327
  (`timeoutMs`, `maxExpressionLength`). Use `createJsonataValueTransform()` to override the bounds.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workbench-kit/field-remap",
3
- "version": "0.0.2-prototype.0.2.32",
3
+ "version": "0.0.2-prototype.0.2.34",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -13,6 +13,9 @@
13
13
  "!src/**/*.stories.ts",
14
14
  "!src/**/*.stories.tsx"
15
15
  ],
16
+ "dependencies": {
17
+ "@workbench-kit/contracts": "0.0.2-prototype.0.2.34"
18
+ },
16
19
  "description": "Field remap runtime: convert structure A into structure B via edges and convertToShape.",
17
20
  "publishConfig": {
18
21
  "access": "public",
@@ -0,0 +1,1143 @@
1
+ import type {
2
+ WorkbenchEditableProjectionDescriptor,
3
+ WorkbenchEditableProjectionPort,
4
+ WorkbenchProjectionSnapshot,
5
+ WorkbenchProjectionTransaction,
6
+ WorkbenchProjectionTransactionResult,
7
+ } from '@workbench-kit/contracts';
8
+ import { normalizeFieldRemapDocument } from '../domain/document/fieldRemapDocument.js';
9
+ import { MAX_TRANSFORM_CHAIN, normalizeMappingEdge } from '../domain/document/mappingEdge.js';
10
+ import {
11
+ MAX_MAPPING_FAN_IN,
12
+ MAX_MAPPING_FAN_OUT,
13
+ normalizeMappingOperators,
14
+ } from '../domain/mapping/mappingOperators.js';
15
+ import { collectSourceFieldIds, collectTargetSlotIds } from '../domain/shapes/shapeEdit.js';
16
+ import { projectShapes } from '../domain/shapes/projectShapes.js';
17
+ import type {
18
+ FieldRemapDocument,
19
+ MappingEdge,
20
+ MappingOperator,
21
+ SourceField,
22
+ TargetSlot,
23
+ } from '../domain/types.js';
24
+
25
+ const MAX_TRANSACTION_ENTRIES = 1_024;
26
+ const MAX_TRANSACTION_OPERATIONS = 256;
27
+ const MAX_HISTORY_ENTRIES = 256;
28
+ const DEFAULT_PERSIST_TIMEOUT_MS = 5_000;
29
+ const MAX_PERSIST_TIMEOUT_MS = 60_000;
30
+ let fallbackOwnerEpoch = 0;
31
+
32
+ export type FieldRemapProjectionOperation =
33
+ | { readonly type: 'upsert-edge'; readonly edge: MappingEdge }
34
+ | { readonly type: 'remove-edge'; readonly edgeId: string }
35
+ | { readonly type: 'upsert-operator'; readonly operator: MappingOperator }
36
+ | { readonly type: 'remove-operator'; readonly operatorId: string };
37
+
38
+ export interface FieldRemapProjectionValue {
39
+ readonly document: FieldRemapDocument;
40
+ readonly sources: readonly SourceField[];
41
+ readonly targets: readonly TargetSlot[];
42
+ readonly includeHidden: boolean;
43
+ }
44
+
45
+ export interface FieldRemapProjectionConflict {
46
+ readonly code: 'stale-canonical-revision';
47
+ }
48
+
49
+ export type FieldRemapPersistenceResult =
50
+ | { readonly status: 'committed' }
51
+ | { readonly status: 'rolled-back' }
52
+ | { readonly status: 'indeterminate' };
53
+
54
+ export interface FieldRemapPersistenceInput {
55
+ readonly previousDocument: FieldRemapDocument;
56
+ readonly nextDocument: FieldRemapDocument;
57
+ readonly expectedRevision: string;
58
+ readonly nextRevision: string;
59
+ readonly signal: AbortSignal;
60
+ }
61
+
62
+ export interface FieldRemapTraversalSample {
63
+ readonly size: 'SMALL' | 'TYPICAL' | 'STRESS';
64
+ readonly aggregateEntries: number;
65
+ readonly visitedEntries: number;
66
+ readonly stages: {
67
+ readonly normalization: number;
68
+ readonly fingerprint: number;
69
+ readonly translation: number;
70
+ readonly freeze: number;
71
+ readonly reprojection: number;
72
+ };
73
+ }
74
+
75
+ export interface CreateFieldRemapProjectionOwnerOptions {
76
+ readonly id: string;
77
+ readonly document: FieldRemapDocument;
78
+ readonly sources: readonly SourceField[];
79
+ readonly targets: readonly TargetSlot[];
80
+ readonly sourceShapeRevision: string;
81
+ readonly targetShapeRevision: string;
82
+ readonly transformRevision?: string;
83
+ readonly publicationRevision?: string;
84
+ readonly includeHidden?: boolean;
85
+ readonly maxTransactionEntries?: number;
86
+ readonly persistTimeoutMs?: number;
87
+ readonly onTraversal?: (sample: FieldRemapTraversalSample) => void;
88
+ readonly persist?: (input: FieldRemapPersistenceInput) => Promise<FieldRemapPersistenceResult>;
89
+ }
90
+
91
+ export interface ReplaceFieldRemapSemanticInputs {
92
+ readonly sources: readonly SourceField[];
93
+ readonly targets: readonly TargetSlot[];
94
+ readonly sourceShapeRevision: string;
95
+ readonly targetShapeRevision: string;
96
+ readonly transformRevision?: string;
97
+ readonly publicationRevision?: string;
98
+ }
99
+
100
+ export interface FieldRemapSemanticHistoryEntry {
101
+ readonly transactionId: string;
102
+ readonly canonicalRevision: string;
103
+ readonly document: FieldRemapDocument;
104
+ }
105
+
106
+ export interface FieldRemapPreviewTicket {
107
+ readonly canonicalRevision: string;
108
+ }
109
+
110
+ export interface FieldRemapProjectionOwner {
111
+ readonly port: WorkbenchEditableProjectionPort<
112
+ FieldRemapProjectionValue,
113
+ FieldRemapProjectionOperation,
114
+ FieldRemapProjectionConflict
115
+ >;
116
+ getCanonicalDocument(): FieldRemapDocument;
117
+ getHistory(): readonly FieldRemapSemanticHistoryEntry[];
118
+ getRetentionSize(): number;
119
+ isReconciliationPending(): boolean;
120
+ createPreviewTicket(): FieldRemapPreviewTicket;
121
+ isPreviewTicketCurrent(ticket: FieldRemapPreviewTicket): boolean;
122
+ replaceSemanticInputs(input: ReplaceFieldRemapSemanticInputs): Promise<void>;
123
+ dispose(): Promise<void>;
124
+ }
125
+
126
+ type NormalizedOperation = FieldRemapProjectionOperation;
127
+
128
+ interface SemanticInputs {
129
+ readonly sources: readonly SourceField[];
130
+ readonly targets: readonly TargetSlot[];
131
+ readonly sourceShapeRevision: string;
132
+ readonly targetShapeRevision: string;
133
+ readonly transformRevision: string;
134
+ readonly publicationRevision: string;
135
+ }
136
+
137
+ interface Reservation {
138
+ readonly sequence: number;
139
+ readonly fingerprint: string;
140
+ readonly promise: Promise<WorkbenchProjectionTransactionResult<FieldRemapProjectionConflict>>;
141
+ resolve(result: WorkbenchProjectionTransactionResult<FieldRemapProjectionConflict>): void;
142
+ terminal: boolean;
143
+ }
144
+
145
+ type TraversalStage = keyof FieldRemapTraversalSample['stages'];
146
+
147
+ interface TraversalCounter {
148
+ readonly aggregateEntries: number;
149
+ readonly stages: Record<TraversalStage, number>;
150
+ }
151
+
152
+ function createTraversalCounter(aggregateEntries: number): TraversalCounter {
153
+ return {
154
+ aggregateEntries,
155
+ stages: {
156
+ normalization: 0,
157
+ fingerprint: 0,
158
+ translation: 0,
159
+ freeze: 0,
160
+ reprojection: 0,
161
+ },
162
+ };
163
+ }
164
+
165
+ function visit(
166
+ counter: TraversalCounter | undefined,
167
+ stage: TraversalStage,
168
+ count: number = 1,
169
+ ): void {
170
+ if (counter) {
171
+ counter.stages[stage] += count;
172
+ }
173
+ }
174
+
175
+ function traversalSample(counter: TraversalCounter): FieldRemapTraversalSample {
176
+ const visitedEntries = Object.values(counter.stages).reduce((sum, count) => sum + count, 0);
177
+ return Object.freeze({
178
+ size:
179
+ counter.aggregateEntries <= 32
180
+ ? 'SMALL'
181
+ : counter.aggregateEntries <= 512
182
+ ? 'TYPICAL'
183
+ : 'STRESS',
184
+ aggregateEntries: counter.aggregateEntries,
185
+ visitedEntries,
186
+ stages: Object.freeze({ ...counter.stages }),
187
+ });
188
+ }
189
+
190
+ function isStrictToken(value: unknown): value is string {
191
+ return typeof value === 'string' && value.length > 0 && value === value.trim();
192
+ }
193
+
194
+ function assertRevision(value: string, label: string): void {
195
+ if (!isStrictToken(value)) {
196
+ throw new TypeError(`${label} must be a trimmed, non-empty opaque revision.`);
197
+ }
198
+ }
199
+
200
+ function cloneAndFreeze<T>(
201
+ value: T,
202
+ ancestors: Set<object> = new Set(),
203
+ counter?: TraversalCounter,
204
+ stage: TraversalStage = 'freeze',
205
+ ): T {
206
+ visit(counter, stage);
207
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') {
208
+ return value;
209
+ }
210
+ if (typeof value === 'number') {
211
+ if (!Number.isFinite(value)) {
212
+ throw new TypeError('Projection payload numbers must be finite.');
213
+ }
214
+ return value;
215
+ }
216
+ if (value === undefined) {
217
+ return value;
218
+ }
219
+ if (typeof value !== 'object') {
220
+ throw new TypeError('Projection payload contains an unsupported value.');
221
+ }
222
+ if (ancestors.has(value)) {
223
+ throw new TypeError('Projection payload must be acyclic.');
224
+ }
225
+ ancestors.add(value);
226
+ if (Array.isArray(value)) {
227
+ const clone = value.map((item) => cloneAndFreeze(item, ancestors, counter, stage));
228
+ ancestors.delete(value);
229
+ return Object.freeze(clone) as T;
230
+ }
231
+ const prototype = Object.getPrototypeOf(value);
232
+ if (prototype !== Object.prototype && prototype !== null) {
233
+ ancestors.delete(value);
234
+ throw new TypeError('Projection payload objects must be plain records.');
235
+ }
236
+ const clone: Record<string, unknown> = {};
237
+ for (const [key, item] of Object.entries(value)) {
238
+ if (key === '__proto__' || key === 'prototype' || key === 'constructor') {
239
+ ancestors.delete(value);
240
+ throw new TypeError('Projection payload contains an unsupported record key.');
241
+ }
242
+ clone[key] = cloneAndFreeze(item, ancestors, counter, stage);
243
+ }
244
+ ancestors.delete(value);
245
+ return Object.freeze(clone) as T;
246
+ }
247
+
248
+ function normalizeAndFreezeDocument(document: FieldRemapDocument): FieldRemapDocument {
249
+ return cloneAndFreeze(normalizeFieldRemapDocument(document));
250
+ }
251
+
252
+ function freezeOwnedDocument(
253
+ document: FieldRemapDocument,
254
+ counter?: TraversalCounter,
255
+ ): FieldRemapDocument {
256
+ return cloneAndFreeze(document, new Set(), counter, 'freeze');
257
+ }
258
+
259
+ function frame(tag: string, payload: string): string {
260
+ return `${tag}${payload.length}:${payload}`;
261
+ }
262
+
263
+ function stableSerialize(value: unknown, counter?: TraversalCounter): string {
264
+ visit(counter, 'fingerprint');
265
+ if (value === undefined) {
266
+ return 'u0:';
267
+ }
268
+ if (value === null) {
269
+ return 'n0:';
270
+ }
271
+ if (typeof value === 'string') {
272
+ return frame('s', value);
273
+ }
274
+ if (typeof value === 'boolean') {
275
+ return value ? 'b1:1' : 'b1:0';
276
+ }
277
+ if (typeof value === 'number') {
278
+ return frame('d', Object.is(value, -0) ? '-0' : String(value));
279
+ }
280
+ if (Array.isArray(value)) {
281
+ return frame('a', value.map((entry) => frame('e', stableSerialize(entry, counter))).join(''));
282
+ }
283
+ const record = value as Readonly<Record<string, unknown>>;
284
+ return frame(
285
+ 'o',
286
+ Object.keys(record)
287
+ .sort()
288
+ .map((key) => frame('k', key) + frame('v', stableSerialize(record[key], counter)))
289
+ .join(''),
290
+ );
291
+ }
292
+
293
+ function validTransformIds(value: unknown): value is readonly string[] | undefined {
294
+ return (
295
+ value === undefined ||
296
+ (Array.isArray(value) &&
297
+ value.length <= MAX_TRANSFORM_CHAIN &&
298
+ value.every((id) => isStrictToken(id)))
299
+ );
300
+ }
301
+
302
+ function validOptionSteps(value: unknown, transformCount: number): boolean {
303
+ return value === undefined || (Array.isArray(value) && value.length <= transformCount);
304
+ }
305
+
306
+ function validEdge(value: unknown, depth: number = 0): value is MappingEdge {
307
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
308
+ return false;
309
+ }
310
+ const edge = value as Partial<MappingEdge>;
311
+ if (
312
+ !isStrictToken(edge.id) ||
313
+ !isStrictToken(edge.sourceFieldId) ||
314
+ !isStrictToken(edge.targetSlotId) ||
315
+ !validTransformIds(edge.transformIds) ||
316
+ !validTransformIds(edge.itemTransformIds) ||
317
+ !validOptionSteps(edge.transformOptionSteps, edge.transformIds?.length ?? 0) ||
318
+ !validOptionSteps(edge.itemTransformOptionSteps, edge.itemTransformIds?.length ?? 0) ||
319
+ (edge.itemSourcePath !== undefined && !isStrictToken(edge.itemSourcePath))
320
+ ) {
321
+ return false;
322
+ }
323
+ if (edge.itemEdges === undefined) {
324
+ return true;
325
+ }
326
+ return (
327
+ depth === 0 &&
328
+ Array.isArray(edge.itemEdges) &&
329
+ edge.itemEdges.every((child) => validEdge(child, depth + 1))
330
+ );
331
+ }
332
+
333
+ function validOperator(value: unknown): value is MappingOperator {
334
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
335
+ return false;
336
+ }
337
+ const operator = value as Partial<MappingOperator>;
338
+ if (!isStrictToken(operator.id) || !validTransformIds(operator.transformIds)) {
339
+ return false;
340
+ }
341
+ if (operator.kind === 'combine') {
342
+ return (
343
+ Array.isArray(operator.inputFieldIds) &&
344
+ operator.inputFieldIds.length >= 2 &&
345
+ operator.inputFieldIds.length <= MAX_MAPPING_FAN_IN &&
346
+ operator.inputFieldIds.every((id) => isStrictToken(id)) &&
347
+ isStrictToken(operator.outputSlotId)
348
+ );
349
+ }
350
+ return (
351
+ operator.kind === 'split' &&
352
+ isStrictToken(operator.inputFieldId) &&
353
+ Array.isArray(operator.outputSlotIds) &&
354
+ operator.outputSlotIds.length >= 2 &&
355
+ operator.outputSlotIds.length <= MAX_MAPPING_FAN_OUT &&
356
+ operator.outputSlotIds.every((id) => isStrictToken(id))
357
+ );
358
+ }
359
+
360
+ function validDocument(document: FieldRemapDocument): boolean {
361
+ return (
362
+ document.version === 2 &&
363
+ Array.isArray(document.edges) &&
364
+ document.edges.every((edge) => validEdge(edge)) &&
365
+ (document.operators === undefined ||
366
+ (Array.isArray(document.operators) &&
367
+ document.operators.every((operator) => validOperator(operator)))) &&
368
+ uniqueIds(document.edges) &&
369
+ uniqueIds(document.operators ?? [])
370
+ );
371
+ }
372
+
373
+ function normalizeOperation(
374
+ operation: unknown,
375
+ counter?: TraversalCounter,
376
+ ): NormalizedOperation | null {
377
+ visit(counter, 'normalization');
378
+ if (!operation || typeof operation !== 'object') {
379
+ return null;
380
+ }
381
+
382
+ const record = operation as Record<string, unknown>;
383
+ switch (record.type) {
384
+ case 'upsert-edge': {
385
+ const edge = cloneAndFreeze(record.edge, new Set(), counter, 'normalization');
386
+ if (!validEdge(edge)) {
387
+ return null;
388
+ }
389
+ return cloneAndFreeze(
390
+ { type: 'upsert-edge', edge: normalizeMappingEdge(edge) },
391
+ new Set(),
392
+ counter,
393
+ 'normalization',
394
+ );
395
+ }
396
+ case 'remove-edge':
397
+ return isStrictToken(record.edgeId)
398
+ ? cloneAndFreeze(
399
+ { type: 'remove-edge', edgeId: record.edgeId },
400
+ new Set(),
401
+ counter,
402
+ 'normalization',
403
+ )
404
+ : null;
405
+ case 'upsert-operator': {
406
+ const input = cloneAndFreeze(record.operator, new Set(), counter, 'normalization');
407
+ if (!validOperator(input)) {
408
+ return null;
409
+ }
410
+ const operator = normalizeMappingOperators([input])?.[0];
411
+ return operator
412
+ ? cloneAndFreeze({ type: 'upsert-operator', operator }, new Set(), counter, 'normalization')
413
+ : null;
414
+ }
415
+ case 'remove-operator':
416
+ return isStrictToken(record.operatorId)
417
+ ? cloneAndFreeze(
418
+ { type: 'remove-operator', operatorId: record.operatorId },
419
+ new Set(),
420
+ counter,
421
+ 'normalization',
422
+ )
423
+ : null;
424
+ default:
425
+ return null;
426
+ }
427
+ }
428
+
429
+ function operatorOperandIds(operator: MappingOperator): {
430
+ readonly sources: readonly string[];
431
+ readonly targets: readonly string[];
432
+ } {
433
+ return operator.kind === 'combine'
434
+ ? { sources: operator.inputFieldIds, targets: [operator.outputSlotId] }
435
+ : { sources: [operator.inputFieldId], targets: operator.outputSlotIds };
436
+ }
437
+
438
+ function operatorIsVisible(
439
+ operator: MappingOperator,
440
+ sourceIds: ReadonlySet<string>,
441
+ targetIds: ReadonlySet<string>,
442
+ ): boolean {
443
+ const operands = operatorOperandIds(operator);
444
+ return (
445
+ operands.sources.every((id) => sourceIds.has(id)) &&
446
+ operands.targets.every((id) => targetIds.has(id))
447
+ );
448
+ }
449
+
450
+ function visitShapeTree<T extends { readonly children?: readonly T[] }>(
451
+ values: readonly T[],
452
+ counter: TraversalCounter | undefined,
453
+ ): void {
454
+ if (!counter) {
455
+ return;
456
+ }
457
+ for (const value of values) {
458
+ visit(counter, 'reprojection');
459
+ if (value.children) {
460
+ visitShapeTree(value.children, counter);
461
+ }
462
+ }
463
+ }
464
+
465
+ function visitEdgeTree(edges: readonly MappingEdge[], counter: TraversalCounter | undefined): void {
466
+ if (!counter) {
467
+ return;
468
+ }
469
+ for (const edge of edges) {
470
+ visit(counter, 'reprojection');
471
+ if (edge.itemEdges) {
472
+ visitEdgeTree(edge.itemEdges, counter);
473
+ }
474
+ }
475
+ }
476
+
477
+ function projectValue(
478
+ descriptor: WorkbenchEditableProjectionDescriptor,
479
+ canonicalRevision: string,
480
+ document: FieldRemapDocument,
481
+ semanticInputs: SemanticInputs,
482
+ includeHidden: boolean,
483
+ counter?: TraversalCounter,
484
+ ): WorkbenchProjectionSnapshot<FieldRemapProjectionValue, WorkbenchEditableProjectionDescriptor> {
485
+ visitShapeTree(semanticInputs.sources, counter);
486
+ visitShapeTree(semanticInputs.targets, counter);
487
+ visitEdgeTree(document.edges, counter);
488
+ const projected = projectShapes({
489
+ sources: semanticInputs.sources,
490
+ targets: semanticInputs.targets,
491
+ edges: document.edges,
492
+ options: { includeHidden },
493
+ });
494
+ const sourceIds = collectSourceFieldIds(projected.sources);
495
+ const targetIds = collectTargetSlotIds(projected.targets);
496
+ const operators = document.operators?.filter((operator) => {
497
+ visit(counter, 'reprojection');
498
+ return operatorIsVisible(operator, sourceIds, targetIds);
499
+ });
500
+ const projectedDocument = freezeOwnedDocument(
501
+ {
502
+ version: 2,
503
+ edges: projected.edges ?? [],
504
+ ...(operators && operators.length > 0 ? { operators } : {}),
505
+ },
506
+ counter,
507
+ );
508
+
509
+ return cloneAndFreeze(
510
+ {
511
+ descriptor,
512
+ canonicalRevision,
513
+ value: {
514
+ document: projectedDocument,
515
+ sources: projected.sources,
516
+ targets: projected.targets,
517
+ includeHidden,
518
+ },
519
+ },
520
+ new Set(),
521
+ counter,
522
+ 'freeze',
523
+ );
524
+ }
525
+
526
+ function uniqueIds(values: readonly { readonly id: string }[]): boolean {
527
+ return new Set(values.map((value) => value.id)).size === values.length;
528
+ }
529
+
530
+ function createOwnerEpoch(): string {
531
+ if (typeof globalThis.crypto?.randomUUID === 'function') {
532
+ return globalThis.crypto.randomUUID();
533
+ }
534
+ fallbackOwnerEpoch += 1;
535
+ return `local-${Date.now()}-${fallbackOwnerEpoch}`;
536
+ }
537
+
538
+ /**
539
+ * Package-internal reference owner for the projection contract. It is intentionally absent from
540
+ * the package root: hosts continue to own persistence and the existing callback APIs stay intact.
541
+ */
542
+ export function createFieldRemapProjectionOwner(
543
+ options: CreateFieldRemapProjectionOwnerOptions,
544
+ ): FieldRemapProjectionOwner {
545
+ if (!isStrictToken(options.id)) {
546
+ throw new TypeError('id must be trimmed and non-empty.');
547
+ }
548
+ assertRevision(options.sourceShapeRevision, 'sourceShapeRevision');
549
+ assertRevision(options.targetShapeRevision, 'targetShapeRevision');
550
+ if (options.transformRevision !== undefined) {
551
+ assertRevision(options.transformRevision, 'transformRevision');
552
+ }
553
+ if (options.publicationRevision !== undefined) {
554
+ assertRevision(options.publicationRevision, 'publicationRevision');
555
+ }
556
+
557
+ const ownerToken = options.id.replace(/[^a-zA-Z0-9_-]/g, '-') || 'owner';
558
+ const ownerTransactionPrefix = `field-remap-${ownerToken}-`;
559
+ const ownerEpoch = createOwnerEpoch();
560
+ const transactionPrefix = `${ownerTransactionPrefix}${ownerEpoch}-`;
561
+ const descriptor = Object.freeze({
562
+ id: options.id,
563
+ documentKind: 'workbench.field-remap',
564
+ projectionVersion: 1,
565
+ kind: 'GUI_BUILDER',
566
+ authority: 'ROUND_TRIP_EDITABLE',
567
+ } as const satisfies WorkbenchEditableProjectionDescriptor);
568
+ const requestedMaxEntries = options.maxTransactionEntries ?? MAX_TRANSACTION_ENTRIES;
569
+ const maxEntries = Number.isFinite(requestedMaxEntries)
570
+ ? Math.min(MAX_TRANSACTION_ENTRIES, Math.max(1, Math.trunc(requestedMaxEntries)))
571
+ : MAX_TRANSACTION_ENTRIES;
572
+ const persistTimeoutMs = options.persistTimeoutMs ?? DEFAULT_PERSIST_TIMEOUT_MS;
573
+ if (
574
+ !Number.isInteger(persistTimeoutMs) ||
575
+ persistTimeoutMs < 1 ||
576
+ persistTimeoutMs > MAX_PERSIST_TIMEOUT_MS
577
+ ) {
578
+ throw new TypeError(`persistTimeoutMs must be an integer from 1 to ${MAX_PERSIST_TIMEOUT_MS}.`);
579
+ }
580
+ const persist = options.persist ?? (async () => ({ status: 'committed' }) as const);
581
+ const includeHidden = options.includeHidden === true;
582
+
583
+ const ownedInitialDocument = cloneAndFreeze(options.document);
584
+ if (!validDocument(ownedInitialDocument)) {
585
+ throw new TypeError('Field Remap canonical document is malformed or exceeds owner limits.');
586
+ }
587
+ let document = normalizeAndFreezeDocument(ownedInitialDocument);
588
+ let semanticInputs: SemanticInputs = {
589
+ sources: cloneAndFreeze([...options.sources]),
590
+ targets: cloneAndFreeze([...options.targets]),
591
+ sourceShapeRevision: options.sourceShapeRevision,
592
+ targetShapeRevision: options.targetShapeRevision,
593
+ transformRevision: options.transformRevision ?? 'transform:unversioned',
594
+ publicationRevision: options.publicationRevision ?? 'publication:unversioned',
595
+ };
596
+ let revisionSequence = 0;
597
+ let transactionSequence = 0;
598
+ let expiredThrough = 0;
599
+ let closed = false;
600
+ let reconciliationPending = false;
601
+ let queue: Promise<void> = Promise.resolve();
602
+ let disposePromise: Promise<void> | undefined;
603
+ let activePersistenceAbort: AbortController | undefined;
604
+ const history: FieldRemapSemanticHistoryEntry[] = [];
605
+ const reservations = new Map<string, Reservation>();
606
+
607
+ const revision = (): string => `${ownerToken}:${ownerEpoch}:revision:${revisionSequence}`;
608
+ let snapshot = projectValue(descriptor, revision(), document, semanticInputs, includeHidden);
609
+
610
+ function enqueue(task: () => Promise<void> | void): Promise<void> {
611
+ const run = queue.then(task, task);
612
+ queue = run.catch(() => undefined);
613
+ return run;
614
+ }
615
+
616
+ function isPersistenceResult(value: unknown): value is FieldRemapPersistenceResult {
617
+ if (!value || typeof value !== 'object') {
618
+ return false;
619
+ }
620
+ const status = (value as { readonly status?: unknown }).status;
621
+ return status === 'committed' || status === 'rolled-back' || status === 'indeterminate';
622
+ }
623
+
624
+ async function runPersistence(
625
+ input: Omit<FieldRemapPersistenceInput, 'signal'>,
626
+ ): Promise<FieldRemapPersistenceResult> {
627
+ const controller = new AbortController();
628
+ activePersistenceAbort = controller;
629
+ const aborted = new Promise<FieldRemapPersistenceResult>((resolve) => {
630
+ controller.signal.addEventListener('abort', () => resolve({ status: 'indeterminate' }), {
631
+ once: true,
632
+ });
633
+ });
634
+ const pending: Promise<FieldRemapPersistenceResult> = Promise.resolve()
635
+ .then(() => persist({ ...input, signal: controller.signal }))
636
+ .then((result): FieldRemapPersistenceResult =>
637
+ isPersistenceResult(result) ? result : { status: 'indeterminate' },
638
+ )
639
+ .catch((): FieldRemapPersistenceResult => ({ status: 'indeterminate' }));
640
+ const timeout = setTimeout(() => controller.abort(), persistTimeoutMs);
641
+ try {
642
+ return await Promise.race([pending, aborted]);
643
+ } finally {
644
+ clearTimeout(timeout);
645
+ if (activePersistenceAbort === controller) {
646
+ activePersistenceAbort = undefined;
647
+ }
648
+ }
649
+ }
650
+
651
+ function issueTransactionId(epoch: 'live' | 'closed'): string {
652
+ transactionSequence += 1;
653
+ return `${transactionPrefix}${epoch}-${transactionSequence}`;
654
+ }
655
+
656
+ function transactionSequenceOf(id: string): number | null {
657
+ const match = new RegExp(`^${transactionPrefix}live-(\\d+)$`).exec(id);
658
+ if (!match) {
659
+ return null;
660
+ }
661
+ const sequence = Number(match[1]);
662
+ return Number.isSafeInteger(sequence) && sequence > 0 && sequence <= transactionSequence
663
+ ? sequence
664
+ : null;
665
+ }
666
+
667
+ function immediate(
668
+ result: WorkbenchProjectionTransactionResult<FieldRemapProjectionConflict>,
669
+ ): Promise<WorkbenchProjectionTransactionResult<FieldRemapProjectionConflict>> {
670
+ return Promise.resolve(result);
671
+ }
672
+
673
+ function reject(
674
+ transactionId: string,
675
+ code:
676
+ 'invalid-operation' | 'unsupported-operation' | 'expired-transaction' | 'capacity-exceeded',
677
+ ): WorkbenchProjectionTransactionResult<FieldRemapProjectionConflict> {
678
+ return {
679
+ status: 'rejected',
680
+ transactionId,
681
+ canonicalRevision: revision(),
682
+ code,
683
+ };
684
+ }
685
+
686
+ function unavailable(
687
+ transactionId: string,
688
+ ): WorkbenchProjectionTransactionResult<FieldRemapProjectionConflict> {
689
+ return {
690
+ status: 'failed',
691
+ transactionId,
692
+ code: 'unavailable',
693
+ ...(snapshot.canonicalRevision ? { lastKnownRevision: snapshot.canonicalRevision } : {}),
694
+ };
695
+ }
696
+
697
+ function evictTerminalReservation(): boolean {
698
+ for (const [id, reservation] of reservations) {
699
+ if (!reservation.terminal) {
700
+ continue;
701
+ }
702
+ reservations.delete(id);
703
+ expiredThrough = Math.max(expiredThrough, reservation.sequence);
704
+ return true;
705
+ }
706
+ return false;
707
+ }
708
+
709
+ function hiddenOperatorIds(
710
+ visibleSourceIds: ReadonlySet<string>,
711
+ visibleTargetIds: ReadonlySet<string>,
712
+ counter?: TraversalCounter,
713
+ ): ReadonlySet<string> {
714
+ if (includeHidden || !document.operators?.length) {
715
+ return new Set();
716
+ }
717
+ const operatorIds = new Set<string>();
718
+ for (const operator of document.operators) {
719
+ visit(counter, 'translation');
720
+ if (operatorIsVisible(operator, visibleSourceIds, visibleTargetIds)) {
721
+ continue;
722
+ }
723
+ operatorIds.add(operator.id);
724
+ }
725
+ return operatorIds;
726
+ }
727
+
728
+ function applyOperations(
729
+ operations: readonly NormalizedOperation[],
730
+ counter?: TraversalCounter,
731
+ ):
732
+ | { readonly status: 'accepted'; readonly document: FieldRemapDocument }
733
+ | {
734
+ readonly status: 'rejected';
735
+ readonly code: 'invalid-operation' | 'unsupported-operation';
736
+ } {
737
+ const edgeOrder = document.edges.map((edge) => {
738
+ visit(counter, 'translation');
739
+ return edge.id;
740
+ });
741
+ const edgeIds = new Set(edgeOrder);
742
+ visit(counter, 'translation', edgeOrder.length);
743
+ const edgeById = new Map(
744
+ document.edges.map((edge) => {
745
+ visit(counter, 'translation');
746
+ return [edge.id, edge] as const;
747
+ }),
748
+ );
749
+ const operatorOrder = (document.operators ?? []).map((operator) => {
750
+ visit(counter, 'translation');
751
+ return operator.id;
752
+ });
753
+ const operatorIds = new Set(operatorOrder);
754
+ visit(counter, 'translation', operatorOrder.length);
755
+ const operatorById = new Map(
756
+ (document.operators ?? []).map((operator) => {
757
+ visit(counter, 'translation');
758
+ return [operator.id, operator] as const;
759
+ }),
760
+ );
761
+ visitShapeTree(semanticInputs.sources, counter);
762
+ visitShapeTree(semanticInputs.targets, counter);
763
+ visitEdgeTree(document.edges, counter);
764
+ const visible = projectShapes({
765
+ sources: semanticInputs.sources,
766
+ targets: semanticInputs.targets,
767
+ edges: document.edges,
768
+ options: { includeHidden },
769
+ });
770
+ const visibleSourceIds = collectSourceFieldIds(visible.sources);
771
+ visitShapeTree(visible.sources, counter);
772
+ const visibleTargetIds = collectTargetSlotIds(visible.targets);
773
+ visitShapeTree(visible.targets, counter);
774
+ const visibleEdgeIds = new Set(
775
+ (visible.edges ?? []).map((edge) => {
776
+ visit(counter, 'translation');
777
+ return edge.id;
778
+ }),
779
+ );
780
+ const omittedOperatorIds = hiddenOperatorIds(visibleSourceIds, visibleTargetIds, counter);
781
+ const rejected = (
782
+ code: 'invalid-operation' | 'unsupported-operation',
783
+ ): { readonly status: 'rejected'; readonly code: typeof code } => ({
784
+ status: 'rejected',
785
+ code,
786
+ });
787
+
788
+ for (const operation of operations) {
789
+ visit(counter, 'translation');
790
+ switch (operation.type) {
791
+ case 'upsert-edge': {
792
+ const current = edgeById.get(operation.edge.id);
793
+ if (
794
+ !visibleSourceIds.has(operation.edge.sourceFieldId) ||
795
+ !visibleTargetIds.has(operation.edge.targetSlotId) ||
796
+ (current !== undefined && !visibleEdgeIds.has(current.id))
797
+ ) {
798
+ return rejected('unsupported-operation');
799
+ }
800
+ if (!edgeIds.has(operation.edge.id)) {
801
+ edgeIds.add(operation.edge.id);
802
+ edgeOrder.push(operation.edge.id);
803
+ }
804
+ edgeById.set(operation.edge.id, operation.edge);
805
+ break;
806
+ }
807
+ case 'remove-edge': {
808
+ const current = edgeById.get(operation.edgeId);
809
+ if (!current) {
810
+ return rejected('invalid-operation');
811
+ }
812
+ if (!visibleEdgeIds.has(current.id)) {
813
+ return rejected('unsupported-operation');
814
+ }
815
+ edgeById.delete(operation.edgeId);
816
+ break;
817
+ }
818
+ case 'upsert-operator': {
819
+ const current = operatorById.get(operation.operator.id);
820
+ if (
821
+ omittedOperatorIds.has(operation.operator.id) ||
822
+ (current !== undefined && omittedOperatorIds.has(current.id)) ||
823
+ !operatorIsVisible(operation.operator, visibleSourceIds, visibleTargetIds)
824
+ ) {
825
+ return rejected('unsupported-operation');
826
+ }
827
+ if (!operatorIds.has(operation.operator.id)) {
828
+ operatorIds.add(operation.operator.id);
829
+ operatorOrder.push(operation.operator.id);
830
+ }
831
+ operatorById.set(operation.operator.id, operation.operator);
832
+ break;
833
+ }
834
+ case 'remove-operator': {
835
+ if (!operatorById.has(operation.operatorId)) {
836
+ return rejected('invalid-operation');
837
+ }
838
+ if (omittedOperatorIds.has(operation.operatorId)) {
839
+ return rejected('unsupported-operation');
840
+ }
841
+ operatorById.delete(operation.operatorId);
842
+ break;
843
+ }
844
+ }
845
+ }
846
+
847
+ const edges = edgeOrder.flatMap((id) => {
848
+ visit(counter, 'translation');
849
+ const edge = edgeById.get(id);
850
+ return edge ? [edge] : [];
851
+ });
852
+ const operators = operatorOrder.flatMap((id) => {
853
+ visit(counter, 'translation');
854
+ const operator = operatorById.get(id);
855
+ return operator ? [operator] : [];
856
+ });
857
+ return {
858
+ status: 'accepted',
859
+ document: freezeOwnedDocument(
860
+ {
861
+ version: 2,
862
+ edges,
863
+ ...(operators.length > 0 ? { operators } : {}),
864
+ },
865
+ counter,
866
+ ),
867
+ };
868
+ }
869
+
870
+ function applyTransaction(
871
+ transaction: WorkbenchProjectionTransaction<FieldRemapProjectionOperation>,
872
+ ): Promise<WorkbenchProjectionTransactionResult<FieldRemapProjectionConflict>> {
873
+ const candidate = transaction as Partial<
874
+ WorkbenchProjectionTransaction<FieldRemapProjectionOperation>
875
+ > | null;
876
+ const transactionId = candidate && typeof candidate.id === 'string' ? candidate.id : '';
877
+ if (closed) {
878
+ return immediate(unavailable(transactionId));
879
+ }
880
+ if (reconciliationPending) {
881
+ return immediate(unavailable(transactionId));
882
+ }
883
+ if (
884
+ !candidate ||
885
+ !isStrictToken(candidate.id) ||
886
+ !isStrictToken(candidate.projectionId) ||
887
+ !isStrictToken(candidate.baseRevision) ||
888
+ candidate.projectionId !== descriptor.id ||
889
+ !Array.isArray(candidate.operations) ||
890
+ candidate.operations.length === 0 ||
891
+ candidate.operations.length > MAX_TRANSACTION_OPERATIONS
892
+ ) {
893
+ return immediate(reject(transactionId, 'invalid-operation'));
894
+ }
895
+
896
+ const traversal = options.onTraversal
897
+ ? createTraversalCounter(
898
+ semanticInputs.sources.length +
899
+ semanticInputs.targets.length +
900
+ document.edges.length +
901
+ (document.operators?.length ?? 0) +
902
+ candidate.operations.length,
903
+ )
904
+ : undefined;
905
+ let normalizedOperations: readonly NormalizedOperation[];
906
+ let fingerprint: string;
907
+ try {
908
+ const normalized: NormalizedOperation[] = [];
909
+ for (const operation of candidate.operations) {
910
+ const ownedOperation = normalizeOperation(operation, traversal);
911
+ if (!ownedOperation) {
912
+ return immediate(reject(transactionId, 'invalid-operation'));
913
+ }
914
+ normalized.push(ownedOperation);
915
+ }
916
+ normalizedOperations = Object.freeze(normalized);
917
+ fingerprint = stableSerialize(
918
+ {
919
+ projectionId: candidate.projectionId,
920
+ baseRevision: candidate.baseRevision,
921
+ operations: normalizedOperations,
922
+ },
923
+ traversal,
924
+ );
925
+ } catch {
926
+ return immediate(reject(transactionId, 'invalid-operation'));
927
+ }
928
+ const admittedId = candidate.id;
929
+ const admittedBaseRevision = candidate.baseRevision;
930
+
931
+ const retained = reservations.get(admittedId);
932
+ if (retained) {
933
+ return retained.fingerprint === fingerprint
934
+ ? retained.promise
935
+ : immediate(reject(admittedId, 'invalid-operation'));
936
+ }
937
+
938
+ const sequence = transactionSequenceOf(admittedId);
939
+ if (sequence === null) {
940
+ return immediate(
941
+ reject(
942
+ admittedId,
943
+ admittedId.startsWith(ownerTransactionPrefix) && /-live-\d+$/.test(admittedId)
944
+ ? 'expired-transaction'
945
+ : 'invalid-operation',
946
+ ),
947
+ );
948
+ }
949
+ if (sequence <= expiredThrough) {
950
+ return immediate(reject(admittedId, 'expired-transaction'));
951
+ }
952
+ while (reservations.size >= maxEntries && evictTerminalReservation()) {
953
+ // Keep evicting terminal work until one slot is available.
954
+ }
955
+ if (reservations.size >= maxEntries) {
956
+ return immediate(reject(admittedId, 'capacity-exceeded'));
957
+ }
958
+
959
+ let settle!: (
960
+ result: WorkbenchProjectionTransactionResult<FieldRemapProjectionConflict>,
961
+ ) => void;
962
+ const promise = new Promise<WorkbenchProjectionTransactionResult<FieldRemapProjectionConflict>>(
963
+ (resolve) => {
964
+ settle = resolve;
965
+ },
966
+ );
967
+ const reservation: Reservation = {
968
+ sequence,
969
+ fingerprint,
970
+ promise,
971
+ resolve: settle,
972
+ terminal: false,
973
+ };
974
+ reservations.set(admittedId, reservation);
975
+
976
+ void enqueue(async () => {
977
+ let result: WorkbenchProjectionTransactionResult<FieldRemapProjectionConflict>;
978
+ try {
979
+ if (closed || reconciliationPending) {
980
+ result = unavailable(admittedId);
981
+ } else if (admittedBaseRevision !== revision()) {
982
+ result = {
983
+ status: 'conflict',
984
+ transactionId: admittedId,
985
+ currentRevision: revision(),
986
+ conflicts: [{ code: 'stale-canonical-revision' }],
987
+ };
988
+ } else {
989
+ const translated = applyOperations(normalizedOperations, traversal);
990
+ if (translated.status === 'rejected') {
991
+ result = reject(admittedId, translated.code);
992
+ } else {
993
+ const expectedRevision = revision();
994
+ const nextRevision = `${ownerToken}:${ownerEpoch}:revision:${revisionSequence + 1}`;
995
+ const persistenceResult = await runPersistence({
996
+ previousDocument: document,
997
+ nextDocument: translated.document,
998
+ expectedRevision,
999
+ nextRevision,
1000
+ });
1001
+ const semanticRevisionDrifted = revision() !== expectedRevision;
1002
+
1003
+ if (closed) {
1004
+ if (persistenceResult.status !== 'rolled-back') {
1005
+ reconciliationPending = true;
1006
+ }
1007
+ result = unavailable(admittedId);
1008
+ } else if (semanticRevisionDrifted) {
1009
+ if (persistenceResult.status === 'rolled-back') {
1010
+ result = {
1011
+ status: 'conflict',
1012
+ transactionId: admittedId,
1013
+ currentRevision: revision(),
1014
+ conflicts: [{ code: 'stale-canonical-revision' }],
1015
+ };
1016
+ } else {
1017
+ reconciliationPending = true;
1018
+ result = unavailable(admittedId);
1019
+ }
1020
+ } else if (persistenceResult.status === 'committed') {
1021
+ const nextSnapshot = projectValue(
1022
+ descriptor,
1023
+ nextRevision,
1024
+ translated.document,
1025
+ semanticInputs,
1026
+ includeHidden,
1027
+ traversal,
1028
+ );
1029
+ document = translated.document;
1030
+ revisionSequence += 1;
1031
+ snapshot = nextSnapshot;
1032
+ history.push(
1033
+ Object.freeze({
1034
+ transactionId: admittedId,
1035
+ canonicalRevision: revision(),
1036
+ document,
1037
+ }),
1038
+ );
1039
+ if (history.length > MAX_HISTORY_ENTRIES) {
1040
+ history.shift();
1041
+ }
1042
+ result = {
1043
+ status: 'applied',
1044
+ transactionId: admittedId,
1045
+ canonicalRevision: revision(),
1046
+ };
1047
+ } else if (persistenceResult.status === 'rolled-back') {
1048
+ result = {
1049
+ status: 'failed',
1050
+ transactionId: admittedId,
1051
+ code: 'commit-failed',
1052
+ canonicalRevision: revision(),
1053
+ };
1054
+ } else {
1055
+ reconciliationPending = true;
1056
+ result = unavailable(admittedId);
1057
+ }
1058
+ }
1059
+ }
1060
+ } catch {
1061
+ reconciliationPending = true;
1062
+ result = unavailable(admittedId);
1063
+ }
1064
+
1065
+ reservation.terminal = true;
1066
+ if (traversal) {
1067
+ options.onTraversal?.(traversalSample(traversal));
1068
+ }
1069
+ reservation.resolve(result);
1070
+ });
1071
+
1072
+ return promise;
1073
+ }
1074
+
1075
+ const port: FieldRemapProjectionOwner['port'] = Object.freeze({
1076
+ descriptor,
1077
+ getSnapshot: () => snapshot,
1078
+ createTransaction: (operations: readonly FieldRemapProjectionOperation[]) => ({
1079
+ id: issueTransactionId(closed ? 'closed' : 'live'),
1080
+ projectionId: descriptor.id,
1081
+ baseRevision: snapshot.canonicalRevision,
1082
+ operations,
1083
+ }),
1084
+ applyTransaction,
1085
+ });
1086
+
1087
+ return {
1088
+ port,
1089
+ getCanonicalDocument: () => document,
1090
+ getHistory: () => Object.freeze([...history]),
1091
+ getRetentionSize: () => reservations.size,
1092
+ isReconciliationPending: () => reconciliationPending,
1093
+ createPreviewTicket: () => ({ canonicalRevision: snapshot.canonicalRevision }),
1094
+ isPreviewTicketCurrent: (ticket) =>
1095
+ !closed && !reconciliationPending && ticket.canonicalRevision === revision(),
1096
+ replaceSemanticInputs: (input) => {
1097
+ assertRevision(input.sourceShapeRevision, 'sourceShapeRevision');
1098
+ assertRevision(input.targetShapeRevision, 'targetShapeRevision');
1099
+ if (input.transformRevision !== undefined) {
1100
+ assertRevision(input.transformRevision, 'transformRevision');
1101
+ }
1102
+ if (input.publicationRevision !== undefined) {
1103
+ assertRevision(input.publicationRevision, 'publicationRevision');
1104
+ }
1105
+ if (closed || reconciliationPending) {
1106
+ return Promise.resolve();
1107
+ }
1108
+ const next: SemanticInputs = {
1109
+ sources: cloneAndFreeze([...input.sources]),
1110
+ targets: cloneAndFreeze([...input.targets]),
1111
+ sourceShapeRevision: input.sourceShapeRevision,
1112
+ targetShapeRevision: input.targetShapeRevision,
1113
+ transformRevision: input.transformRevision ?? semanticInputs.transformRevision,
1114
+ publicationRevision: input.publicationRevision ?? semanticInputs.publicationRevision,
1115
+ };
1116
+ if (
1117
+ next.sourceShapeRevision === semanticInputs.sourceShapeRevision &&
1118
+ next.targetShapeRevision === semanticInputs.targetShapeRevision &&
1119
+ next.transformRevision === semanticInputs.transformRevision &&
1120
+ next.publicationRevision === semanticInputs.publicationRevision
1121
+ ) {
1122
+ return Promise.resolve();
1123
+ }
1124
+ const nextRevision = `${ownerToken}:${ownerEpoch}:revision:${revisionSequence + 1}`;
1125
+ const nextSnapshot = projectValue(descriptor, nextRevision, document, next, includeHidden);
1126
+ semanticInputs = next;
1127
+ revisionSequence += 1;
1128
+ snapshot = nextSnapshot;
1129
+ return Promise.resolve();
1130
+ },
1131
+ dispose: () => {
1132
+ if (disposePromise) {
1133
+ return disposePromise;
1134
+ }
1135
+ closed = true;
1136
+ activePersistenceAbort?.abort();
1137
+ disposePromise = queue.then(() => {
1138
+ reservations.clear();
1139
+ });
1140
+ return disposePromise;
1141
+ },
1142
+ };
1143
+ }