@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 +54 -1
- package/package.json +4 -1
- package/src/projection/serializedOwner.ts +1143 -0
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
|
|
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.
|
|
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
|
+
}
|