jinaga
Version:
Data management for web and mobile applications.
217 lines (183 loc) • 12.3 kB
text/typescript
import { describeSpecification } from '../specification/description';
import { Specification } from "../specification/specification";
import { FactEnvelope, FactRecord, ProjectedResult, Storage } from '../storage';
import { computeStringHash } from '../util/encoding';
import { Trace } from '../util/trace';
export interface SpecificationListener {
onResult(results: ProjectedResult[]): Promise<void>;
}
export class ObservableSource {
private listenersByTypeAndSpecification: Map<string, Map<string, {
specification: Specification,
listeners: SpecificationListener[]
}>> = new Map();
constructor(private store: Storage) {
}
async notify(saved: FactEnvelope[]): Promise<void> {
// Collect all notification promises to ensure all callbacks complete
const notificationPromises: Promise<void>[] = [];
for (let index = 0; index < saved.length; index++) {
const envelope = saved[index];
notificationPromises.push(this.notifyFactSaved(envelope.fact));
}
// Wait for all notifications to complete before resolving
await Promise.all(notificationPromises);
}
public addSpecificationListener(specification: Specification, onResult: (results: ProjectedResult[]) => Promise<void>): SpecificationListener {
if (specification.given.length !== 1) {
throw new Error("Specification must have exactly one given fact");
}
const givenType = specification.given[0].label.type;
const givenName = specification.given[0].label.name;
const specificationKey = computeStringHash(describeSpecification(specification, 0));
const hasNestedSpecs = specification.projection.type === "composite" &&
specification.projection.components.some(c => c.type === "specification");
Trace.info(`[ObservableSource] ADD_LISTENER REQUEST - Type: ${givenType}, Name: ${givenName}, Spec key: ${specificationKey.substring(0, 8)}..., Has nested specs: ${hasNestedSpecs}`);
let listenersBySpecification = this.listenersByTypeAndSpecification.get(givenType);
if (!listenersBySpecification) {
listenersBySpecification = new Map();
this.listenersByTypeAndSpecification.set(givenType, listenersBySpecification);
Trace.info(`[ObservableSource] Created new listener map for type: ${givenType}`);
}
let listeners = listenersBySpecification.get(specificationKey);
if (!listeners) {
listeners = {
specification,
listeners: []
};
listenersBySpecification.set(specificationKey, listeners);
Trace.info(`[ObservableSource] Created new listener group for spec: ${specificationKey.substring(0, 8)}... (type: ${givenType})`);
}
const specificationListener = {
onResult
};
listeners.listeners.push(specificationListener);
const listenerCount = listeners.listeners.length;
const totalListeners = Array.from(this.listenersByTypeAndSpecification.values())
.reduce((total, map) => total + Array.from(map.values()).reduce((sum, l) => sum + l.listeners.length, 0), 0);
Trace.info(`[ObservableSource] LISTENER ADDED - Spec: ${specificationKey.substring(0, 8)}..., Type: ${givenType}, Count for spec: ${listenerCount}, Total listeners: ${totalListeners}, Nested specs: ${hasNestedSpecs}`);
return specificationListener;
}
public removeSpecificationListener(specificationListener: SpecificationListener) {
const startTime = Date.now();
let found = false;
let removedFromSpec = '';
let removedFromType = '';
for (const [givenType, listenersBySpecification] of this.listenersByTypeAndSpecification) {
for (const [specificationKey, listeners] of listenersBySpecification) {
const beforeCount = listeners.listeners.length;
const index = listeners.listeners.indexOf(specificationListener);
if (index >= 0) {
Trace.info(`[ObservableSource] REMOVING listener - Spec: ${specificationKey.substring(0, 8)}..., Type: ${givenType}, Index: ${index}, Before count: ${beforeCount}`);
listeners.listeners.splice(index, 1);
found = true;
removedFromSpec = specificationKey;
removedFromType = givenType;
const afterCount = listeners.listeners.length;
Trace.info(`[ObservableSource] REMOVED listener - After count: ${afterCount}`);
if (listeners.listeners.length === 0) {
listenersBySpecification.delete(specificationKey);
Trace.info(`[ObservableSource] Deleted empty spec group: ${specificationKey.substring(0, 8)}...`);
if (listenersBySpecification.size === 0) {
this.listenersByTypeAndSpecification.delete(givenType);
Trace.info(`[ObservableSource] Deleted empty type group: ${givenType}`);
}
}
break;
}
}
if (found) break;
}
const totalListeners = Array.from(this.listenersByTypeAndSpecification.values())
.reduce((total, map) => total + Array.from(map.values()).reduce((sum, l) => sum + l.listeners.length, 0), 0);
const duration = Date.now() - startTime;
if (found) {
Trace.info(`[ObservableSource] Listener removal completed - Spec: ${removedFromSpec.substring(0, 8)}..., Type: ${removedFromType}, Total remaining: ${totalListeners}, Duration: ${duration}ms`);
} else {
Trace.warn(`[ObservableSource] Listener NOT FOUND during removal - Total listeners: ${totalListeners}, Duration: ${duration}ms`);
}
}
private async notifyFactSaved(fact: FactRecord) {
const startTime = Date.now();
const listenersBySpecification = this.listenersByTypeAndSpecification.get(fact.type);
if (listenersBySpecification) {
Trace.info(`[ObservableSource] NOTIFY START - Fact type: ${fact.type}, Hash: ${fact.hash.substring(0, 8)}..., Spec groups: ${listenersBySpecification.size}`);
let totalNotifications = 0;
let specCount = 0;
let nestedSpecCount = 0;
for (const [specificationKey, listeners] of listenersBySpecification) {
specCount++;
if (listeners && listeners.listeners.length > 0) {
const listenerCount = listeners.listeners.length;
const specification = listeners.specification;
const hasNestedSpecs = specification.projection.type === "composite" &&
specification.projection.components.some(c => c.type === "specification");
if (hasNestedSpecs) {
nestedSpecCount++;
const nestedSpecNames = specification.projection.type === "composite"
? specification.projection.components
.filter(c => c.type === "specification")
.map(c => c.name)
.join(', ')
: '';
Trace.info(`[ObservableSource] NESTED SPEC DETECTED - Spec ${specCount}/${listenersBySpecification.size}, Key: ${specificationKey.substring(0, 8)}..., Nested components: [${nestedSpecNames}], Listeners: ${listenerCount}`);
} else {
Trace.info(`[ObservableSource] Processing spec ${specCount}/${listenersBySpecification.size} - Key: ${specificationKey.substring(0, 8)}..., Listeners: ${listenerCount}`);
}
const givenReference = {
type: fact.type,
hash: fact.hash
};
const readStart = Date.now();
const results = await this.store.read([givenReference], specification);
const readDuration = Date.now() - readStart;
if (hasNestedSpecs) {
Trace.info(`[ObservableSource] Store read for NESTED spec - Results: ${results.length}, Duration: ${readDuration}ms`);
// Log nested result structure if present
if (results.length > 0 && specification.projection.type === "composite") {
const nestedResults = specification.projection.components
.filter(c => c.type === "specification")
.map(c => `${c.name}: ${results[0].result[c.name]?.length || 0}`)
.join(', ');
Trace.info(`[ObservableSource] Nested results structure: {${nestedResults}}`);
}
} else {
Trace.info(`[ObservableSource] Store read completed - Results: ${results.length}, Duration: ${readDuration}ms`);
}
// Create a snapshot of listeners to avoid modification during iteration
const listenerSnapshot = [...listeners.listeners];
if (listenerSnapshot.length !== listeners.listeners.length) {
Trace.warn(`[ObservableSource] RACE CONDITION DETECTED - Listener count changed during snapshot: ${listenerSnapshot.length} vs ${listeners.listeners.length}`);
}
for (let i = 0; i < listenerSnapshot.length; i++) {
const specificationListener = listenerSnapshot[i];
if (specificationListener) {
try {
const notifyStart = Date.now();
Trace.info(`[ObservableSource] Calling listener ${i+1}/${listenerSnapshot.length} - Nested: ${hasNestedSpecs}`);
await specificationListener.onResult(results);
const notifyDuration = Date.now() - notifyStart;
totalNotifications++;
if (notifyDuration > 100) {
Trace.warn(`[ObservableSource] SLOW notification - Listener ${i+1}/${listenerSnapshot.length}, Duration: ${notifyDuration}ms, Nested: ${hasNestedSpecs}`);
} else {
Trace.info(`[ObservableSource] Listener completed - ${i+1}/${listenerSnapshot.length}, Duration: ${notifyDuration}ms`);
}
} catch (error) {
Trace.error(`[ObservableSource] ERROR in listener notification - Listener ${i+1}/${listenerSnapshot.length}, Nested: ${hasNestedSpecs}, Error: ${error}`);
}
} else {
Trace.warn(`[ObservableSource] NULL listener encountered at index ${i}`);
}
}
} else {
Trace.info(`[ObservableSource] Skipping spec ${specCount}/${listenersBySpecification.size} - No listeners or null group`);
}
}
const totalDuration = Date.now() - startTime;
Trace.info(`[ObservableSource] NOTIFY COMPLETE - Fact: ${fact.hash.substring(0, 8)}..., Type: ${fact.type}, Specs processed: ${specCount} (${nestedSpecCount} nested), Total notifications: ${totalNotifications}, Duration: ${totalDuration}ms`);
} else {
Trace.info(`[ObservableSource] No listeners for fact type: ${fact.type} - Available types: [${Array.from(this.listenersByTypeAndSpecification.keys()).join(', ')}]`);
}
}
}