UNPKG

jinaga

Version:

Data management for web and mobile applications.

484 lines (407 loc) 21 kB
import { Condition, ExistentialCondition, isExistentialCondition, Label, Match, PathCondition, Projection, Specification } from "./specification"; import { detectDisconnectedSpecification } from "./UnionFind"; import { Trace } from "../util/trace"; import { describeSpecification } from "./description"; import { computeStringHash } from "../util/encoding"; type InverseOperation = "add" | "remove"; export interface SpecificationInverse { inverseSpecification: Specification; operation: InverseOperation; givenSubset: string[]; parentSubset: string[]; path: string; resultSubset: string[]; }; interface InverterContext { givenSubset: string[]; parentSubset: string[]; path: string; resultSubset: string[]; projection: Projection; } export function invertSpecification(specification: Specification): SpecificationInverse[] { const givenTypes = specification.given.map(g => g.label.type).join(', '); const givenNames = specification.given.map(g => g.label.name).join(', '); const matchCount = specification.matches.length; Trace.info(`[InvertSpec] START - Given types: [${givenTypes}], Given names: [${givenNames}], Matches: ${matchCount}`); // Detect disconnected specifications before inversion detectDisconnectedSpecification(specification); // Turn each given into a match. const emptyMatches: Match[] = specification.given.map(g => ({ unknown: g.label, conditions: g.conditions })); const matches: Match[] = [...emptyMatches, ...specification.matches]; const labels: Label[] = [...specification.given.map(g => g.label), ...specification.matches.map(m => m.unknown)]; const givenSubset: string[] = specification.given.map(g => g.label.name); const matchLabels: Label[] = specification.matches.map(m => m.unknown); const resultSubset: string[] = [ ...givenSubset, ...matchLabels.map(l => l.name) ]; const context: InverterContext = { path: "", givenSubset, parentSubset: givenSubset, resultSubset, projection: specification.projection }; Trace.info(`[InvertSpec] Inverting matches - Labels: ${labels.length}, Context path: "${context.path}"`); const inverses: SpecificationInverse[] = invertMatches(matches, labels, context); Trace.info(`[InvertSpec] Match inverses generated: ${inverses.length}`); Trace.info(`[InvertSpec] Inverting projection - Projection type: ${specification.projection.type}`); const projectionInverses: SpecificationInverse[] = invertProjection(matches, context); Trace.info(`[InvertSpec] Projection inverses generated: ${projectionInverses.length}`); // Check if self-inverse is needed and create it const selfInverse = createSelfInverse(specification, context); const selfInverseCount = selfInverse ? 1 : 0; if (selfInverse) { Trace.info(`[InvertSpec] Self-inverse created for given type: ${specification.given[0].label.type}`); } const totalInverses = inverses.length + projectionInverses.length + selfInverseCount; Trace.info(`[InvertSpec] COMPLETE - Total inverses: ${totalInverses} (${inverses.length} match + ${projectionInverses.length} projection + ${selfInverseCount} self-inverse)`); // Deduplicate inverses based on specification structure const allInverses = selfInverse ? [...inverses, ...projectionInverses, selfInverse] : [...inverses, ...projectionInverses]; const deduplicatedInverses = deduplicateInverses(allInverses); Trace.info(`[InvertSpec] Deduplication - Before: ${allInverses.length}, After: ${deduplicatedInverses.length}`); return deduplicatedInverses; } /** * Removes duplicate inverse specifications based on their structure. * Two inverses are considered duplicates if they have: * - Identical inverse specification structure * - Same operation (add/remove) * - Same metadata (givenSubset, parentSubset, path, resultSubset) */ function deduplicateInverses(inverses: SpecificationInverse[]): SpecificationInverse[] { const seen = new Map<string, SpecificationInverse>(); for (const inverse of inverses) { // Create a unique key from the inverse specification and metadata const specKey = computeStringHash(describeSpecification(inverse.inverseSpecification, 0)); const metadataKey = JSON.stringify({ operation: inverse.operation, givenSubset: inverse.givenSubset, parentSubset: inverse.parentSubset, path: inverse.path, resultSubset: inverse.resultSubset }); const key = `${specKey}|${metadataKey}`; if (!seen.has(key)) { seen.set(key, inverse); } else { Trace.info(`[InvertSpec] Skipping duplicate inverse - Spec key: ${specKey.substring(0, 8)}..., Operation: ${inverse.operation}`); } } return Array.from(seen.values()); } function invertMatches(matches: Match[], labels: Label[], context: InverterContext): SpecificationInverse[] { const inverses: SpecificationInverse[] = []; // Produce an inverse for each unknown in the original specification. for (const label of labels) { matches = shakeTree(matches, label.name); // The given will not have any successors. // Simplify the matches by removing any conditions that cannot be satisfied. const simplified: Match[] | null = simplifyMatches(matches, label.name); if (simplified !== null) { const inverseSpecification: Specification = { given: [{label, conditions: simplified[0].conditions.filter(isExistentialCondition) as ExistentialCondition[]}], matches: simplified.slice(1), projection: context.projection }; const inverse: SpecificationInverse = { inverseSpecification, operation: "add", givenSubset: context.givenSubset, parentSubset: context.parentSubset, path: context.path, resultSubset: context.resultSubset }; inverses.push(inverse); } const existentialInverses: SpecificationInverse[] = invertExistentialConditions(matches, matches[0].conditions, "add", context); inverses.push(...existentialInverses); } return inverses; } function shakeTree(matches: Match[], label: string): Match[] { // Find the match for the given label. const match: Match = findMatch(matches, label); // Move the match to the beginning of the list. matches = [ match, ...matches.filter(m => m !== match) ]; // Invert all path conditions in the match and move them to the tagged match. for (const condition of match.conditions) { if (condition.type === "path") { matches = invertAndMovePathCondition(matches, label, condition); } } // Move any other matches with no paths down. for (let i = 1; i < matches.length; i++) { let otherMatch: Match = matches[i]; const firstLabel = otherMatch.unknown.name; while (!otherMatch.conditions.some(c => c.type === "path")) { // Find all matches beyond this point that tag this one. for (let j = i + 1; j < matches.length; j++) { const taggedMatch: Match = matches[j]; // Move their path conditions to the other match. for (const taggedCondition of taggedMatch.conditions) { if (taggedCondition.type === "path" && taggedCondition.labelRight === otherMatch.unknown.name) { matches = invertAndMovePathCondition(matches, taggedMatch.unknown.name, taggedCondition); } } } // Move the other match to the bottom of the list. matches = [ ...matches.slice(0, i), ...matches.slice(i + 1), matches[i] ]; otherMatch = matches[i]; // If we have returned to the first match, we have found an infinite loop. if (otherMatch.unknown.name === firstLabel) { const remainingLabelTypes = matches.slice(i).map(m => m.unknown.type).join(", "); throw new Error(`The labels with types [${remainingLabelTypes}] are not connected to the rest of the graph`); } } } return matches; } function invertAndMovePathCondition(matches: Match[], label: string, pathCondition: PathCondition): Match[] { // Find the match for the given label. const match: Match = findMatch(matches, label); // Find the match for the target label. const targetMatch: Match = findMatch(matches, pathCondition.labelRight); // Invert the path condition. const invertedPathCondition: PathCondition = { type: "path", labelRight: match.unknown.name, rolesRight: pathCondition.rolesLeft, rolesLeft: pathCondition.rolesRight }; // Remove the path condition from the match. const newMatch: Match = { unknown: match.unknown, conditions: match.conditions.filter(c => c !== pathCondition) }; const matchIndex = matches.indexOf(match); matches = [ ...matches.slice(0, matchIndex), newMatch, ...matches.slice(matchIndex + 1) ]; // Add the inverted path condition to the target match. const newTargetMatch: Match = { unknown: targetMatch.unknown, conditions: [ invertedPathCondition, ...targetMatch.conditions ] }; const targetMatchIndex = matches.indexOf(targetMatch); matches = [ ...matches.slice(0, targetMatchIndex), newTargetMatch, ...matches.slice(targetMatchIndex + 1) ]; return matches; } function findMatch(matches: Match[], label: string): Match { for (const match of matches) { if (match.unknown.name === label) { return match; } } throw new Error(`Label ${label} not found`); } function invertExistentialConditions(outerMatches: Match[], conditions: Condition[], parentOperation: InverseOperation, context: InverterContext): SpecificationInverse[] { const inverses: SpecificationInverse[] = []; // Produce inverses for each existential condition in the match. for (const condition of conditions) { if (condition.type === "existential") { let matches = [ ...outerMatches, ...condition.matches ]; for (const match of condition.matches) { matches = shakeTree(matches, match.unknown.name); const matchesWithoutCondition: Match[] = removeCondition(matches, condition); const simplifiedMatches: Match[] | null = simplifyMatches(matchesWithoutCondition, match.unknown.name); if (simplifiedMatches === null) { // The matches in the existential condition are unsatisfiable. continue; } const inverseSpecification: Specification = { given: [{ label: match.unknown, conditions: simplifiedMatches[0].conditions.filter(isExistentialCondition) as ExistentialCondition[] }], matches: simplifiedMatches.slice(1), projection: context.projection }; const operation = inferOperation(parentOperation, condition.exists); const inverse: SpecificationInverse = { inverseSpecification, operation, givenSubset: context.givenSubset, parentSubset: context.parentSubset, path: context.path, resultSubset: context.resultSubset }; inverses.push(inverse); const existentialInverses: SpecificationInverse[] = invertExistentialConditions(matches, match.conditions, operation, context); inverses.push(...existentialInverses); } } } return inverses; } function removeCondition(matches: Match[], condition: ExistentialCondition): Match[] { return matches.map(match => match.conditions.includes(condition) ? { unknown: match.unknown, conditions: match.conditions.filter(c => c !== condition) } : match ); } function inferOperation(parentOperation: InverseOperation, exists: boolean): InverseOperation { if (parentOperation === "add") { return exists ? "add" : "remove"; } else if (parentOperation === "remove") { return exists ? "remove" : "add"; } else { const _exhaustiveCheck: never = parentOperation; throw new Error(`Cannot infer operation from ${_exhaustiveCheck}, ${exists ? "exists" : "not exists"}`); } } function invertProjection(matches: Match[], context: InverterContext): SpecificationInverse[] { const inverses: SpecificationInverse[] = []; // Produce inverses for all collections in the projection. if (context.projection.type === "composite") { const specComponents = context.projection.components.filter(c => c.type === "specification"); Trace.info(`[InvertProjection] Processing composite projection - Path: "${context.path}", Spec components: ${specComponents.length}/${context.projection.components.length}`); for (const component of context.projection.components) { if (component.type === "specification") { const componentMatches = [ ...matches, ...component.matches ]; const componentLabels = component.matches.map(m => m.unknown); const childPath = context.path + "." + component.name; Trace.info(`[InvertProjection] NESTED SPEC - Component: ${component.name}, Path: "${childPath}", Component matches: ${component.matches.length}, Component labels: ${componentLabels.length}`); const childContext: InverterContext = { ...context, path: childPath, parentSubset: context.resultSubset, resultSubset: [ ...context.resultSubset, ...componentLabels.map(l => l.name) ], projection: component.projection }; Trace.info(`[InvertProjection] Child context - Path: "${childPath}", Parent subset: [${childContext.parentSubset.join(', ')}], Result subset: [${childContext.resultSubset.join(', ')}]`); const matchInverses = invertMatches(componentMatches, componentLabels, childContext); Trace.info(`[InvertProjection] Generated ${matchInverses.length} match inverses for nested spec "${component.name}"`); const projectionInverses = invertProjection(componentMatches, childContext); Trace.info(`[InvertProjection] Generated ${projectionInverses.length} projection inverses for nested spec "${component.name}"`); inverses.push(...matchInverses, ...projectionInverses); } } } else { Trace.info(`[InvertProjection] Non-composite projection - Path: "${context.path}", Type: ${context.projection.type}`); } return inverses; } function simplifyMatches(matches: Match[], given: string): Match[] | null { const simplifiedMatches: Match[] = []; for (const match of matches) { const simplifiedMatch: Match | null = simplifyMatch(match, given); if (simplifiedMatch === null) { return null; } else { simplifiedMatches.push(simplifiedMatch); } } return simplifiedMatches; } function simplifyMatch(match: Match, given: string): Match | null { const simplifiedConditions: Condition[] = []; for (const condition of match.conditions) { if (expectsSuccessor(condition, given)) { // This path condition matches successors of the given. // There are no successors yet, so the condition is unsatisfiable. return null; } let simplifiedCondition: Condition = condition; if (condition.type === "existential") { // Simplify the matches in the existential condition. const simplifiedMatches: Match[] | null = simplifyMatches(condition.matches, given); if (simplifiedMatches === null) { if (condition.exists) { // The matches in the existential condition are unsatisfiable. return null; } else { // The matches in the existential condition are unsatisfiable. // The existential condition is always true, so we can skip it. continue; } } const anyExpectsSuccessor = simplifiedMatches.some(m => m.conditions.some(c => expectsSuccessor(c, given))); if (anyExpectsSuccessor) { if (condition.exists) { // This existential condition expects successors of the given. // There are no successors yet, so the condition is unsatisfiable. return null; } else { // This existential condition expects successors of the given. // There are no successors yet, so the condition is always true. continue; } } simplifiedCondition = { type: "existential", exists: condition.exists, matches: simplifiedMatches }; } simplifiedConditions.push(simplifiedCondition); } const simplifiedMatch: Match = { unknown: match.unknown, conditions: simplifiedConditions }; return simplifiedMatch; } function expectsSuccessor(condition: Condition, given: string) { return condition.type === "path" && condition.labelRight === given && condition.rolesRight.length === 0 && condition.rolesLeft.length > 0; } /** * Creates a self-inverse for the specification if needed. * * Self-inverse allows the specification to react when its own given fact arrives. * This is critical for scenarios where: * 1. A subscription is started with an unpersisted given fact * 2. The given fact is later persisted * 3. The system needs to re-read the specification with the now-available given * * Safety constraints (to avoid infinite loops): * - ONLY for specifications with a single given fact * - No complex conditions on the given * - Uses the original specification as-is (no actual inversion) * * @param specification The original specification * @param context The inverter context * @returns A self-inverse SpecificationInverse or null if not needed */ function createSelfInverse(specification: Specification, context: InverterContext): SpecificationInverse | null { // Safety check: Only support single given fact // Multiple givens are too complex and risk infinite loops if (specification.given.length !== 1) { Trace.info(`[SelfInverse] Skipping - Multiple givens (${specification.given.length})`); return null; } const given = specification.given[0]; const givenType = given.label.type; const givenName = given.label.name; // Safety check: No complex conditions on given // Complex conditions could cause unexpected behavior if (given.conditions.length > 0) { Trace.info(`[SelfInverse] Skipping - Given has conditions (${given.conditions.length})`); return null; } // Create self-inverse: When the given fact type arrives, re-read the entire specification // The inverseSpecification is the ORIGINAL specification (not inverted) // This triggers a complete re-evaluation when the given becomes available const selfInverse: SpecificationInverse = { inverseSpecification: specification, operation: "add", givenSubset: context.givenSubset, parentSubset: context.parentSubset, path: context.path, resultSubset: context.resultSubset }; Trace.info(`[SelfInverse] Created for given: ${givenType} (${givenName})`); return selfInverse; }