@continuum-dev/runtime 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +403 -0
- package/index.d.ts +5 -0
- package/index.d.ts.map +1 -0
- package/index.js +4 -0
- package/lib/context.d.ts +128 -0
- package/lib/context.d.ts.map +1 -0
- package/lib/context.js +305 -0
- package/lib/reconcile.d.ts +17 -0
- package/lib/reconcile.d.ts.map +1 -0
- package/lib/reconcile.js +76 -0
- package/lib/reconciliation/collection-resolver.d.ts +13 -0
- package/lib/reconciliation/collection-resolver.d.ts.map +1 -0
- package/lib/reconciliation/collection-resolver.js +283 -0
- package/lib/reconciliation/component-resolver.d.ts +10 -0
- package/lib/reconciliation/component-resolver.d.ts.map +1 -0
- package/lib/reconciliation/differ.d.ts +12 -0
- package/lib/reconciliation/differ.d.ts.map +1 -0
- package/lib/reconciliation/differ.js +102 -0
- package/lib/reconciliation/migrator.d.ts +13 -0
- package/lib/reconciliation/migrator.d.ts.map +1 -0
- package/lib/reconciliation/migrator.js +92 -0
- package/lib/reconciliation/node-resolver.d.ts +10 -0
- package/lib/reconciliation/node-resolver.d.ts.map +1 -0
- package/lib/reconciliation/node-resolver.js +190 -0
- package/lib/reconciliation/state-builder.d.ts +13 -0
- package/lib/reconciliation/state-builder.d.ts.map +1 -0
- package/lib/reconciliation/state-builder.js +211 -0
- package/lib/reconciliation/validator.d.ts +14 -0
- package/lib/reconciliation/validator.d.ts.map +1 -0
- package/lib/reconciliation/validator.js +100 -0
- package/lib/reconciliation/view-traversal.d.ts +14 -0
- package/lib/reconciliation/view-traversal.d.ts.map +1 -0
- package/lib/reconciliation/view-traversal.js +72 -0
- package/lib/types.d.ts +153 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +1 -0
- package/package.json +44 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { ISSUE_CODES, ISSUE_SEVERITY } from '@continuum/contract';
|
|
2
|
+
import { findPriorNode, determineNodeMatchStrategy, findNewNodeByPriorNode, resolvePriorSnapshotId, } from '../context.js';
|
|
3
|
+
import { addedDiff, addedResolution, typeChangedDiff, detachedResolution, migratedDiff, migratedResolution, carriedResolution, removedDiff, restoredDiff, restoredResolution, } from './differ.js';
|
|
4
|
+
import { attemptMigration } from './migrator.js';
|
|
5
|
+
import { carryValuesMeta } from './state-builder.js';
|
|
6
|
+
import { validateNodeValue } from './validator.js';
|
|
7
|
+
import { createInitialCollectionValue, reconcileCollectionValue } from './collection-resolver.js';
|
|
8
|
+
export function resolveAllNodes(ctx, priorValues, priorData, now, options) {
|
|
9
|
+
const acc = {
|
|
10
|
+
values: {},
|
|
11
|
+
valueLineage: {},
|
|
12
|
+
detachedValues: {},
|
|
13
|
+
restoredDetachedKeys: new Set(),
|
|
14
|
+
diffs: [],
|
|
15
|
+
resolutions: [],
|
|
16
|
+
issues: [],
|
|
17
|
+
};
|
|
18
|
+
for (const [newId, newNode] of ctx.newById) {
|
|
19
|
+
const priorNode = findPriorNode(ctx, newNode);
|
|
20
|
+
const priorNodeId = priorNode ? (ctx.priorNodeIds.get(priorNode) ?? priorNode.id) : null;
|
|
21
|
+
const priorValue = priorValues.get(newId) ?? (priorNodeId ? priorData.values[priorNodeId] : undefined);
|
|
22
|
+
const matchedBy = determineNodeMatchStrategy(ctx, newNode, priorNode);
|
|
23
|
+
if (!priorNode) {
|
|
24
|
+
resolveNewNode(acc, newId, newNode, priorData, now);
|
|
25
|
+
}
|
|
26
|
+
else if (newNode.type === 'collection' && priorNode.type === 'collection') {
|
|
27
|
+
resolveCollectionNode(acc, newId, priorNode, priorNodeId, newNode, matchedBy, priorValue, priorData, now, options);
|
|
28
|
+
}
|
|
29
|
+
else if (priorNode.type !== newNode.type && !areCompatibleContainerTypes(priorNode.type, newNode.type)) {
|
|
30
|
+
resolveTypeMismatchedNode(acc, newId, priorNode, priorNodeId, newNode, matchedBy, priorValue, ctx, now);
|
|
31
|
+
}
|
|
32
|
+
else if (hasNodeHashChanged(priorNode, newNode)) {
|
|
33
|
+
resolveHashChangedNode(acc, newId, priorNode, priorNodeId, newNode, matchedBy, priorValue, priorData, now, options);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
resolveUnchangedNode(acc, newId, priorNode, priorNodeId, newNode, matchedBy, priorValue, priorData, now);
|
|
37
|
+
}
|
|
38
|
+
acc.issues.push(...validateNodeValue(newNode, acc.values[newId]));
|
|
39
|
+
}
|
|
40
|
+
return acc;
|
|
41
|
+
}
|
|
42
|
+
function resolveNewNode(acc, newId, newNode, priorData, now) {
|
|
43
|
+
const detachedKey = newNode.key ?? newId;
|
|
44
|
+
const detachedValue = priorData.detachedValues?.[detachedKey];
|
|
45
|
+
if (detachedValue && detachedValue.previousNodeType === newNode.type) {
|
|
46
|
+
acc.values[newId] = detachedValue.value;
|
|
47
|
+
acc.restoredDetachedKeys.add(detachedKey);
|
|
48
|
+
acc.diffs.push(restoredDiff(newId, detachedValue.value));
|
|
49
|
+
acc.resolutions.push(restoredResolution(newId, newNode.type, detachedValue.value));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (newNode.type === 'collection') {
|
|
53
|
+
acc.values[newId] = createInitialCollectionValue(newNode);
|
|
54
|
+
}
|
|
55
|
+
else if ('defaultValue' in newNode && newNode.defaultValue !== undefined) {
|
|
56
|
+
acc.values[newId] = { value: newNode.defaultValue };
|
|
57
|
+
}
|
|
58
|
+
acc.diffs.push(addedDiff(newId));
|
|
59
|
+
acc.resolutions.push(addedResolution(newId, newNode.type));
|
|
60
|
+
}
|
|
61
|
+
function resolveCollectionNode(acc, newId, priorNode, priorNodeId, newNode, matchedBy, priorValue, priorData, now, options) {
|
|
62
|
+
const result = reconcileCollectionValue(priorNode, newNode, priorValue, options);
|
|
63
|
+
acc.values[newId] = result.value;
|
|
64
|
+
carryValuesMeta(acc.valueLineage, newId, priorNodeId, priorData, now, result.didMigrateItems);
|
|
65
|
+
acc.issues.push(...result.issues);
|
|
66
|
+
if (result.didMigrateItems) {
|
|
67
|
+
acc.diffs.push(migratedDiff(newId, priorValue, result.value));
|
|
68
|
+
acc.resolutions.push(migratedResolution(newId, priorNodeId, matchedBy ?? 'id', priorNode.type, newNode.type, priorValue, result.value));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
acc.resolutions.push(carriedResolution(newId, priorNodeId, matchedBy ?? 'id', priorNode.type, priorValue, result.value));
|
|
72
|
+
}
|
|
73
|
+
function resolveTypeMismatchedNode(acc, newId, priorNode, priorNodeId, newNode, matchedBy, priorValue, ctx, now) {
|
|
74
|
+
acc.issues.push({
|
|
75
|
+
severity: ISSUE_SEVERITY.ERROR,
|
|
76
|
+
nodeId: newId,
|
|
77
|
+
message: `Node type mismatch: ${priorNode.type} -> ${newNode.type}`,
|
|
78
|
+
code: ISSUE_CODES.TYPE_MISMATCH,
|
|
79
|
+
});
|
|
80
|
+
acc.diffs.push(typeChangedDiff(newId, priorValue, priorNode.type, newNode.type));
|
|
81
|
+
acc.resolutions.push(detachedResolution(newId, priorNodeId, matchedBy, priorNode.type, newNode.type, priorValue));
|
|
82
|
+
if (priorValue !== undefined) {
|
|
83
|
+
const detachedKey = priorNode.key ?? priorNodeId;
|
|
84
|
+
acc.detachedValues[detachedKey] = {
|
|
85
|
+
value: priorValue,
|
|
86
|
+
previousNodeType: priorNode.type,
|
|
87
|
+
key: priorNode.key,
|
|
88
|
+
detachedAt: now,
|
|
89
|
+
viewVersion: ctx.priorView?.version ?? 'unknown',
|
|
90
|
+
reason: 'type-mismatch',
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Container types (row, grid, group) are semantically equivalent — they all
|
|
96
|
+
* hold children. The AI may swap between them for styling reasons. Treating
|
|
97
|
+
* these as type mismatches would destroy all child values unnecessarily.
|
|
98
|
+
*/
|
|
99
|
+
const CONTAINER_TYPES = new Set(['row', 'grid', 'group']);
|
|
100
|
+
function areCompatibleContainerTypes(a, b) {
|
|
101
|
+
return CONTAINER_TYPES.has(a) && CONTAINER_TYPES.has(b);
|
|
102
|
+
}
|
|
103
|
+
function hasNodeHashChanged(priorNode, newNode) {
|
|
104
|
+
return !!(priorNode.hash && newNode.hash && priorNode.hash !== newNode.hash);
|
|
105
|
+
}
|
|
106
|
+
function resolveHashChangedNode(acc, newId, priorNode, priorNodeId, newNode, matchedBy, priorValue, priorData, now, options) {
|
|
107
|
+
const migrationResult = attemptMigration(newId, priorNode, newNode, priorValue, options);
|
|
108
|
+
if (migrationResult.kind === 'migrated') {
|
|
109
|
+
acc.values[newId] = migrationResult.value;
|
|
110
|
+
carryValuesMeta(acc.valueLineage, newId, priorNodeId, priorData, now, true);
|
|
111
|
+
acc.diffs.push(migratedDiff(newId, priorValue, migrationResult.value));
|
|
112
|
+
acc.resolutions.push(migratedResolution(newId, priorNodeId, matchedBy, priorNode.type, newNode.type, priorValue, migrationResult.value));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const message = migrationResult.kind === 'error'
|
|
116
|
+
? `Node ${newId} migration failed: ${String(migrationResult.error)}`
|
|
117
|
+
: `Node ${newId} view changed but no migration strategy available`;
|
|
118
|
+
acc.issues.push({
|
|
119
|
+
severity: ISSUE_SEVERITY.WARNING,
|
|
120
|
+
nodeId: newId,
|
|
121
|
+
message,
|
|
122
|
+
code: ISSUE_CODES.MIGRATION_FAILED,
|
|
123
|
+
});
|
|
124
|
+
resolveUnchangedNode(acc, newId, priorNode, priorNodeId, newNode, matchedBy, priorValue, priorData, now);
|
|
125
|
+
}
|
|
126
|
+
function resolveUnchangedNode(acc, newId, priorNode, priorNodeId, newNode, matchedBy, priorValue, priorData, now) {
|
|
127
|
+
if (priorValue !== undefined) {
|
|
128
|
+
const priorNodeValue = priorValue;
|
|
129
|
+
const resolvedValue = { ...priorNodeValue };
|
|
130
|
+
// Check if AI provided a new defaultValue that differs from prior
|
|
131
|
+
if ('defaultValue' in newNode && newNode.defaultValue !== undefined) {
|
|
132
|
+
if ('defaultValue' in priorNode) {
|
|
133
|
+
if (JSON.stringify(priorNode.defaultValue) !== JSON.stringify(newNode.defaultValue)) {
|
|
134
|
+
if (priorNodeValue.isDirty) {
|
|
135
|
+
resolvedValue.suggestion = newNode.defaultValue;
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
resolvedValue.value = newNode.defaultValue;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
if (priorNodeValue.isDirty) {
|
|
144
|
+
resolvedValue.suggestion = newNode.defaultValue;
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
resolvedValue.value = newNode.defaultValue;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
acc.values[newId] = resolvedValue;
|
|
152
|
+
carryValuesMeta(acc.valueLineage, newId, priorNodeId, priorData, now, false);
|
|
153
|
+
}
|
|
154
|
+
acc.resolutions.push(carriedResolution(newId, priorNodeId, matchedBy, priorNode.type, priorValue, priorValue !== undefined ? priorValue : undefined));
|
|
155
|
+
}
|
|
156
|
+
export function detectRemovedNodes(ctx, priorData, options, now) {
|
|
157
|
+
const diffs = [];
|
|
158
|
+
const issues = [];
|
|
159
|
+
const detachedValues = {};
|
|
160
|
+
for (const [priorId, priorValue] of Object.entries(priorData.values)) {
|
|
161
|
+
const resolvedPriorId = resolvePriorSnapshotId(ctx, priorId) ?? priorId;
|
|
162
|
+
const priorComp = ctx.priorById.get(resolvedPriorId);
|
|
163
|
+
const keyMatchedNode = priorComp ? findNewNodeByPriorNode(ctx, priorComp) : null;
|
|
164
|
+
const stillExists = ctx.newById.has(resolvedPriorId) ||
|
|
165
|
+
!!keyMatchedNode;
|
|
166
|
+
if (!stillExists) {
|
|
167
|
+
diffs.push(removedDiff(resolvedPriorId, priorValue));
|
|
168
|
+
if (priorValue !== undefined) {
|
|
169
|
+
const detachedKey = priorComp?.key ?? resolvedPriorId;
|
|
170
|
+
detachedValues[detachedKey] = {
|
|
171
|
+
value: priorValue,
|
|
172
|
+
previousNodeType: priorComp?.type ?? 'unknown',
|
|
173
|
+
key: priorComp?.key,
|
|
174
|
+
detachedAt: now,
|
|
175
|
+
viewVersion: ctx.priorView?.version ?? 'unknown',
|
|
176
|
+
reason: 'node-removed',
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
if (!options.allowPartialRestore) {
|
|
180
|
+
issues.push({
|
|
181
|
+
severity: ISSUE_SEVERITY.WARNING,
|
|
182
|
+
nodeId: resolvedPriorId,
|
|
183
|
+
message: `Node ${resolvedPriorId} was removed from view`,
|
|
184
|
+
code: ISSUE_CODES.NODE_REMOVED,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return { diffs, issues, detachedValues };
|
|
190
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { DataSnapshot, DetachedValue, ValueLineage, ViewDefinition } from '@continuum/contract';
|
|
2
|
+
import type { NodeResolutionAccumulator, ReconciliationIssue, ReconciliationOptions, ReconciliationResult, StateDiff } from '../types.js';
|
|
3
|
+
export declare function buildFreshSessionResult(newView: ViewDefinition, now: number): ReconciliationResult;
|
|
4
|
+
export declare function buildBlindCarryResult(newView: ViewDefinition, priorData: DataSnapshot, now: number, options: ReconciliationOptions): ReconciliationResult;
|
|
5
|
+
export declare function assembleReconciliationResult(resolved: NodeResolutionAccumulator, removals: {
|
|
6
|
+
diffs: StateDiff[];
|
|
7
|
+
issues: ReconciliationIssue[];
|
|
8
|
+
detachedValues?: Record<string, DetachedValue>;
|
|
9
|
+
}, priorData: DataSnapshot, newView: ViewDefinition, now: number): ReconciliationResult;
|
|
10
|
+
export declare function carryValuesMeta(target: Record<string, ValueLineage>, newId: string, priorId: string, priorData: DataSnapshot, now: number, isMigrated: boolean): void;
|
|
11
|
+
export declare function computeViewHash(view: ViewDefinition): string | undefined;
|
|
12
|
+
export declare function generateSessionId(now: number): string;
|
|
13
|
+
//# sourceMappingURL=state-builder.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"state-builder.d.ts","sourceRoot":"","sources":["../../../../../packages/runtime/src/lib/reconciliation/state-builder.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,YAAY,EACZ,aAAa,EAEb,YAAY,EACZ,cAAc,EAEf,MAAM,qBAAqB,CAAC;AAO7B,OAAO,KAAK,EACV,yBAAyB,EACzB,mBAAmB,EACnB,qBAAqB,EACrB,oBAAoB,EACpB,SAAS,EAEV,MAAM,aAAa,CAAC;AAIrB,wBAAgB,uBAAuB,CACrC,OAAO,EAAE,cAAc,EACvB,GAAG,EAAE,MAAM,GACV,oBAAoB,CA8BtB;AA8CD,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,cAAc,EACvB,SAAS,EAAE,YAAY,EACvB,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,qBAAqB,GAC7B,oBAAoB,CA4EtB;AAED,wBAAgB,4BAA4B,CAC1C,QAAQ,EAAE,yBAAyB,EACnC,QAAQ,EAAE;IAAE,KAAK,EAAE,SAAS,EAAE,CAAC;IAAC,MAAM,EAAE,mBAAmB,EAAE,CAAC;IAAC,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,CAAA;CAAE,EAC/G,SAAS,EAAE,YAAY,EACvB,OAAO,EAAE,cAAc,EACvB,GAAG,EAAE,MAAM,GACV,oBAAoB,CA8BtB;AAED,wBAAgB,eAAe,CAC7B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,EACpC,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,YAAY,EACvB,GAAG,EAAE,MAAM,EACX,UAAU,EAAE,OAAO,GAClB,IAAI,CAON;AAED,wBAAgB,eAAe,CAAC,IAAI,EAAE,cAAc,GAAG,MAAM,GAAG,SAAS,CAmBxE;AAED,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAErD"}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { ISSUE_CODES, ISSUE_SEVERITY, } from '@continuum/contract';
|
|
2
|
+
import { collectDuplicateIssues } from '../context.js';
|
|
3
|
+
import { traverseViewNodes } from './view-traversal.js';
|
|
4
|
+
import { addedDiff, addedResolution } from './differ.js';
|
|
5
|
+
import { createInitialCollectionValue } from './collection-resolver.js';
|
|
6
|
+
export function buildFreshSessionResult(newView, now) {
|
|
7
|
+
const values = {};
|
|
8
|
+
const diffs = [];
|
|
9
|
+
const resolutions = [];
|
|
10
|
+
collectNodesAsFreshlyAdded(newView.nodes, values, diffs, resolutions);
|
|
11
|
+
const duplicateIssues = collectDuplicateIssues(newView.nodes);
|
|
12
|
+
return {
|
|
13
|
+
reconciledState: {
|
|
14
|
+
values,
|
|
15
|
+
lineage: {
|
|
16
|
+
timestamp: now,
|
|
17
|
+
sessionId: generateSessionId(now),
|
|
18
|
+
viewId: newView.viewId,
|
|
19
|
+
viewVersion: newView.version,
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
diffs,
|
|
23
|
+
issues: [
|
|
24
|
+
{
|
|
25
|
+
severity: ISSUE_SEVERITY.INFO,
|
|
26
|
+
message: 'No prior state found, starting fresh',
|
|
27
|
+
code: ISSUE_CODES.NO_PRIOR_DATA,
|
|
28
|
+
},
|
|
29
|
+
...duplicateIssues,
|
|
30
|
+
],
|
|
31
|
+
resolutions,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function collectNodesAsFreshlyAdded(nodes, values, diffs, resolutions) {
|
|
35
|
+
const traversal = traverseViewNodes(nodes);
|
|
36
|
+
for (const entry of traversal.visited) {
|
|
37
|
+
const node = entry.node;
|
|
38
|
+
const nodeId = entry.nodeId;
|
|
39
|
+
if (node.type === 'collection') {
|
|
40
|
+
values[nodeId] = createInitialCollectionValue(node);
|
|
41
|
+
}
|
|
42
|
+
else if ('defaultValue' in node && node.defaultValue !== undefined) {
|
|
43
|
+
values[nodeId] = { value: node.defaultValue };
|
|
44
|
+
}
|
|
45
|
+
diffs.push(addedDiff(nodeId));
|
|
46
|
+
resolutions.push(addedResolution(nodeId, node.type));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function collectNodeIds(nodes) {
|
|
50
|
+
const ids = new Set();
|
|
51
|
+
const traversal = traverseViewNodes(nodes);
|
|
52
|
+
for (const entry of traversal.visited) {
|
|
53
|
+
ids.add(entry.nodeId);
|
|
54
|
+
}
|
|
55
|
+
return ids;
|
|
56
|
+
}
|
|
57
|
+
function collectNodeKeyToIdMap(nodes) {
|
|
58
|
+
const keyToId = new Map();
|
|
59
|
+
const traversal = traverseViewNodes(nodes);
|
|
60
|
+
for (const entry of traversal.visited) {
|
|
61
|
+
const node = entry.node;
|
|
62
|
+
if (node.key) {
|
|
63
|
+
const scopedKey = buildNodePath(entry.parentPath, node.key);
|
|
64
|
+
if (!keyToId.has(scopedKey)) {
|
|
65
|
+
keyToId.set(scopedKey, entry.nodeId);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return keyToId;
|
|
70
|
+
}
|
|
71
|
+
export function buildBlindCarryResult(newView, priorData, now, options) {
|
|
72
|
+
const duplicateIssues = collectDuplicateIssues(newView.nodes);
|
|
73
|
+
const issues = [
|
|
74
|
+
{
|
|
75
|
+
severity: ISSUE_SEVERITY.WARNING,
|
|
76
|
+
message: 'Prior data exists but no prior view provided; cannot reconcile',
|
|
77
|
+
code: ISSUE_CODES.NO_PRIOR_VIEW,
|
|
78
|
+
},
|
|
79
|
+
...duplicateIssues,
|
|
80
|
+
];
|
|
81
|
+
if (options.allowBlindCarry) {
|
|
82
|
+
const newIds = collectNodeIds(newView.nodes);
|
|
83
|
+
const keyToId = collectNodeKeyToIdMap(newView.nodes);
|
|
84
|
+
const carriedValues = {};
|
|
85
|
+
const carriedNodeIds = new Set();
|
|
86
|
+
for (const [id, value] of Object.entries(priorData.values)) {
|
|
87
|
+
if (newIds.has(id)) {
|
|
88
|
+
carriedValues[id] = value;
|
|
89
|
+
carriedNodeIds.add(id);
|
|
90
|
+
issues.push({
|
|
91
|
+
severity: ISSUE_SEVERITY.INFO,
|
|
92
|
+
nodeId: id,
|
|
93
|
+
message: `Node ${id} data carried without view validation`,
|
|
94
|
+
code: ISSUE_CODES.UNVALIDATED_CARRY,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
for (const [id, value] of Object.entries(priorData.values)) {
|
|
99
|
+
if (newIds.has(id)) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const matchedNodeId = keyToId.get(id);
|
|
103
|
+
if (!matchedNodeId || carriedNodeIds.has(matchedNodeId)) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
carriedValues[matchedNodeId] = value;
|
|
107
|
+
carriedNodeIds.add(matchedNodeId);
|
|
108
|
+
issues.push({
|
|
109
|
+
severity: ISSUE_SEVERITY.INFO,
|
|
110
|
+
nodeId: matchedNodeId,
|
|
111
|
+
message: `Node ${id} data carried to ${matchedNodeId} via key match without view validation`,
|
|
112
|
+
code: ISSUE_CODES.UNVALIDATED_CARRY,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
reconciledState: {
|
|
117
|
+
values: carriedValues,
|
|
118
|
+
lineage: {
|
|
119
|
+
...priorData.lineage,
|
|
120
|
+
timestamp: now,
|
|
121
|
+
viewId: newView.viewId,
|
|
122
|
+
viewVersion: newView.version,
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
diffs: [],
|
|
126
|
+
issues,
|
|
127
|
+
resolutions: [],
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
reconciledState: {
|
|
132
|
+
values: {},
|
|
133
|
+
lineage: {
|
|
134
|
+
...priorData.lineage,
|
|
135
|
+
timestamp: now,
|
|
136
|
+
viewId: newView.viewId,
|
|
137
|
+
viewVersion: newView.version,
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
diffs: [],
|
|
141
|
+
issues,
|
|
142
|
+
resolutions: [],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
export function assembleReconciliationResult(resolved, removals, priorData, newView, now) {
|
|
146
|
+
const viewHash = computeViewHash(newView);
|
|
147
|
+
const hasValueLineage = Object.keys(resolved.valueLineage).length > 0;
|
|
148
|
+
const detachedValues = {
|
|
149
|
+
...(priorData.detachedValues ?? {}),
|
|
150
|
+
...(resolved.detachedValues ?? {}),
|
|
151
|
+
...(removals.detachedValues ?? {}),
|
|
152
|
+
};
|
|
153
|
+
for (const restoredKey of resolved.restoredDetachedKeys ?? []) {
|
|
154
|
+
delete detachedValues[restoredKey];
|
|
155
|
+
}
|
|
156
|
+
const hasDetachedValues = Object.keys(detachedValues).length > 0;
|
|
157
|
+
return {
|
|
158
|
+
reconciledState: {
|
|
159
|
+
values: resolved.values,
|
|
160
|
+
lineage: {
|
|
161
|
+
...priorData.lineage,
|
|
162
|
+
timestamp: now,
|
|
163
|
+
viewId: newView.viewId,
|
|
164
|
+
viewVersion: newView.version,
|
|
165
|
+
...(viewHash !== undefined ? { viewHash } : {}),
|
|
166
|
+
},
|
|
167
|
+
...(hasValueLineage ? { valueLineage: resolved.valueLineage } : {}),
|
|
168
|
+
...(hasDetachedValues ? { detachedValues } : {}),
|
|
169
|
+
},
|
|
170
|
+
diffs: [...resolved.diffs, ...removals.diffs],
|
|
171
|
+
issues: [...resolved.issues, ...removals.issues],
|
|
172
|
+
resolutions: resolved.resolutions,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
export function carryValuesMeta(target, newId, priorId, priorData, now, isMigrated) {
|
|
176
|
+
const priorMeta = priorData.valueLineage?.[priorId];
|
|
177
|
+
if (priorMeta) {
|
|
178
|
+
target[newId] = isMigrated
|
|
179
|
+
? { ...priorMeta, lastUpdated: now }
|
|
180
|
+
: { ...priorMeta };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
export function computeViewHash(view) {
|
|
184
|
+
const traversal = traverseViewNodes(view.nodes);
|
|
185
|
+
let hasHash = false;
|
|
186
|
+
const descriptors = traversal.visited.map((entry) => {
|
|
187
|
+
if (entry.node.hash) {
|
|
188
|
+
hasHash = true;
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
positionPath: entry.positionPath,
|
|
192
|
+
nodeId: entry.nodeId,
|
|
193
|
+
type: entry.node.type,
|
|
194
|
+
hash: entry.node.hash ?? null,
|
|
195
|
+
};
|
|
196
|
+
});
|
|
197
|
+
if (!hasHash) {
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
descriptors.sort((a, b) => a.positionPath.localeCompare(b.positionPath));
|
|
201
|
+
return JSON.stringify(descriptors);
|
|
202
|
+
}
|
|
203
|
+
export function generateSessionId(now) {
|
|
204
|
+
return `session_${now}_${Math.random().toString(36).substring(2, 9)}`;
|
|
205
|
+
}
|
|
206
|
+
function buildNodePath(parentPath, nodeId) {
|
|
207
|
+
if (parentPath.length === 0) {
|
|
208
|
+
return nodeId;
|
|
209
|
+
}
|
|
210
|
+
return `${parentPath}/${nodeId}`;
|
|
211
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { NodeValue, ViewNode } from '@continuum/contract';
|
|
2
|
+
import type { ReconciliationIssue } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Validates a node value against contract constraints and emits runtime issues.
|
|
5
|
+
*
|
|
6
|
+
* This helper is exported so host applications can run the same validation
|
|
7
|
+
* semantics used during reconciliation for custom workflows.
|
|
8
|
+
*
|
|
9
|
+
* @param node View node containing optional constraint metadata.
|
|
10
|
+
* @param state Current node state value to validate.
|
|
11
|
+
* @returns Zero or more validation issues.
|
|
12
|
+
*/
|
|
13
|
+
export declare function validateNodeValue(node: ViewNode, state: NodeValue | undefined): ReconciliationIssue[];
|
|
14
|
+
//# sourceMappingURL=validator.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validator.d.ts","sourceRoot":"","sources":["../../../../../packages/runtime/src/lib/reconciliation/validator.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAE/D,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAgBvD;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,QAAQ,EACd,KAAK,EAAE,SAAS,GAAG,SAAS,GAC3B,mBAAmB,EAAE,CA4EvB"}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { ISSUE_CODES, ISSUE_SEVERITY } from '@continuum/contract';
|
|
2
|
+
function readStateValue(state) {
|
|
3
|
+
if (!state) {
|
|
4
|
+
return undefined;
|
|
5
|
+
}
|
|
6
|
+
return state.value;
|
|
7
|
+
}
|
|
8
|
+
function isEmptyValue(value) {
|
|
9
|
+
if (value == null)
|
|
10
|
+
return true;
|
|
11
|
+
if (typeof value === 'string')
|
|
12
|
+
return value.trim().length === 0;
|
|
13
|
+
if (Array.isArray(value))
|
|
14
|
+
return value.length === 0;
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Validates a node value against contract constraints and emits runtime issues.
|
|
19
|
+
*
|
|
20
|
+
* This helper is exported so host applications can run the same validation
|
|
21
|
+
* semantics used during reconciliation for custom workflows.
|
|
22
|
+
*
|
|
23
|
+
* @param node View node containing optional constraint metadata.
|
|
24
|
+
* @param state Current node state value to validate.
|
|
25
|
+
* @returns Zero or more validation issues.
|
|
26
|
+
*/
|
|
27
|
+
export function validateNodeValue(node, state) {
|
|
28
|
+
const constraints = 'constraints' in node ? node.constraints : undefined;
|
|
29
|
+
if (!constraints)
|
|
30
|
+
return [];
|
|
31
|
+
const value = readStateValue(state);
|
|
32
|
+
const issues = [];
|
|
33
|
+
if (constraints.required && isEmptyValue(value)) {
|
|
34
|
+
issues.push({
|
|
35
|
+
severity: ISSUE_SEVERITY.WARNING,
|
|
36
|
+
nodeId: node.id,
|
|
37
|
+
message: `Node ${node.id} failed required validation`,
|
|
38
|
+
code: ISSUE_CODES.VALIDATION_FAILED,
|
|
39
|
+
});
|
|
40
|
+
return issues;
|
|
41
|
+
}
|
|
42
|
+
if (typeof value === 'number') {
|
|
43
|
+
if (typeof constraints.min === 'number' && value < constraints.min) {
|
|
44
|
+
issues.push({
|
|
45
|
+
severity: ISSUE_SEVERITY.WARNING,
|
|
46
|
+
nodeId: node.id,
|
|
47
|
+
message: `Node ${node.id} is below minimum ${constraints.min}`,
|
|
48
|
+
code: ISSUE_CODES.VALIDATION_FAILED,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
if (typeof constraints.max === 'number' && value > constraints.max) {
|
|
52
|
+
issues.push({
|
|
53
|
+
severity: ISSUE_SEVERITY.WARNING,
|
|
54
|
+
nodeId: node.id,
|
|
55
|
+
message: `Node ${node.id} is above maximum ${constraints.max}`,
|
|
56
|
+
code: ISSUE_CODES.VALIDATION_FAILED,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (typeof value === 'string') {
|
|
61
|
+
if (typeof constraints.minLength === 'number' && value.length < constraints.minLength) {
|
|
62
|
+
issues.push({
|
|
63
|
+
severity: ISSUE_SEVERITY.WARNING,
|
|
64
|
+
nodeId: node.id,
|
|
65
|
+
message: `Node ${node.id} is shorter than minLength ${constraints.minLength}`,
|
|
66
|
+
code: ISSUE_CODES.VALIDATION_FAILED,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
if (typeof constraints.maxLength === 'number' && value.length > constraints.maxLength) {
|
|
70
|
+
issues.push({
|
|
71
|
+
severity: ISSUE_SEVERITY.WARNING,
|
|
72
|
+
nodeId: node.id,
|
|
73
|
+
message: `Node ${node.id} is longer than maxLength ${constraints.maxLength}`,
|
|
74
|
+
code: ISSUE_CODES.VALIDATION_FAILED,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
if (constraints.pattern) {
|
|
78
|
+
try {
|
|
79
|
+
const pattern = new RegExp(constraints.pattern);
|
|
80
|
+
if (!pattern.test(value)) {
|
|
81
|
+
issues.push({
|
|
82
|
+
severity: ISSUE_SEVERITY.WARNING,
|
|
83
|
+
nodeId: node.id,
|
|
84
|
+
message: `Node ${node.id} does not match pattern ${constraints.pattern}`,
|
|
85
|
+
code: ISSUE_CODES.VALIDATION_FAILED,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
issues.push({
|
|
91
|
+
severity: ISSUE_SEVERITY.WARNING,
|
|
92
|
+
nodeId: node.id,
|
|
93
|
+
message: `Node ${node.id} has invalid validation pattern`,
|
|
94
|
+
code: ISSUE_CODES.VALIDATION_FAILED,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return issues;
|
|
100
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type ViewNode } from '@continuum/contract';
|
|
2
|
+
import type { ReconciliationIssue } from '../types.js';
|
|
3
|
+
export interface TraversedViewNode {
|
|
4
|
+
node: ViewNode;
|
|
5
|
+
parentPath: string;
|
|
6
|
+
nodeId: string;
|
|
7
|
+
depth: number;
|
|
8
|
+
positionPath: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function traverseViewNodes(nodes: ViewNode[], maxDepth?: number): {
|
|
11
|
+
visited: TraversedViewNode[];
|
|
12
|
+
issues: ReconciliationIssue[];
|
|
13
|
+
};
|
|
14
|
+
//# sourceMappingURL=view-traversal.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"view-traversal.d.ts","sourceRoot":"","sources":["../../../../../packages/runtime/src/lib/reconciliation/view-traversal.ts"],"names":[],"mappings":"AAAA,OAAO,EAA8C,KAAK,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAChG,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAIvD,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,QAAQ,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,YAAY,EAAE,MAAM,CAAC;CACtB;AAeD,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,QAAQ,EAAE,EACjB,QAAQ,SAAyB,GAChC;IAAE,OAAO,EAAE,iBAAiB,EAAE,CAAC;IAAC,MAAM,EAAE,mBAAmB,EAAE,CAAA;CAAE,CAwEjE"}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { getChildNodes, ISSUE_CODES, ISSUE_SEVERITY } from '@continuum/contract';
|
|
2
|
+
const DEFAULT_MAX_VIEW_DEPTH = 128;
|
|
3
|
+
export function traverseViewNodes(nodes, maxDepth = DEFAULT_MAX_VIEW_DEPTH) {
|
|
4
|
+
const visited = [];
|
|
5
|
+
const issues = [];
|
|
6
|
+
const active = new Set();
|
|
7
|
+
const stack = [];
|
|
8
|
+
for (let i = nodes.length - 1; i >= 0; i--) {
|
|
9
|
+
stack.push({
|
|
10
|
+
kind: 'enter',
|
|
11
|
+
node: nodes[i],
|
|
12
|
+
parentPath: '',
|
|
13
|
+
depth: 0,
|
|
14
|
+
positionPath: `${i}`,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
while (stack.length > 0) {
|
|
18
|
+
const frame = stack.pop();
|
|
19
|
+
if (!frame) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (frame.kind === 'exit') {
|
|
23
|
+
active.delete(frame.node);
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (frame.depth > maxDepth) {
|
|
27
|
+
issues.push({
|
|
28
|
+
severity: ISSUE_SEVERITY.ERROR,
|
|
29
|
+
nodeId: toIndexedId(frame.node.id, frame.parentPath),
|
|
30
|
+
message: `View node depth exceeds max depth of ${maxDepth}`,
|
|
31
|
+
code: ISSUE_CODES.VIEW_MAX_DEPTH_EXCEEDED,
|
|
32
|
+
});
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (active.has(frame.node)) {
|
|
36
|
+
issues.push({
|
|
37
|
+
severity: ISSUE_SEVERITY.ERROR,
|
|
38
|
+
nodeId: toIndexedId(frame.node.id, frame.parentPath),
|
|
39
|
+
message: `Cycle detected while traversing children for node ${frame.node.id}`,
|
|
40
|
+
code: ISSUE_CODES.VIEW_CHILD_CYCLE_DETECTED,
|
|
41
|
+
});
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
active.add(frame.node);
|
|
45
|
+
const nodeId = toIndexedId(frame.node.id, frame.parentPath);
|
|
46
|
+
visited.push({
|
|
47
|
+
node: frame.node,
|
|
48
|
+
parentPath: frame.parentPath,
|
|
49
|
+
nodeId,
|
|
50
|
+
depth: frame.depth,
|
|
51
|
+
positionPath: frame.positionPath,
|
|
52
|
+
});
|
|
53
|
+
stack.push({ kind: 'exit', node: frame.node });
|
|
54
|
+
const children = getChildNodes(frame.node);
|
|
55
|
+
for (let i = children.length - 1; i >= 0; i--) {
|
|
56
|
+
stack.push({
|
|
57
|
+
kind: 'enter',
|
|
58
|
+
node: children[i],
|
|
59
|
+
parentPath: nodeId,
|
|
60
|
+
depth: frame.depth + 1,
|
|
61
|
+
positionPath: `${frame.positionPath}.${i}`,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { visited, issues };
|
|
66
|
+
}
|
|
67
|
+
function toIndexedId(id, parentPath) {
|
|
68
|
+
if (parentPath.length > 0) {
|
|
69
|
+
return `${parentPath}/${id}`;
|
|
70
|
+
}
|
|
71
|
+
return id;
|
|
72
|
+
}
|