jinaga
Version:
Data management for web and mobile applications.
484 lines (407 loc) • 21 kB
text/typescript
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;
}