UNPKG

jinaga

Version:

Data management for web and mobile applications.

398 lines (360 loc) 15.3 kB
import { Authentication } from "./authentication/authentication"; import { dehydrateReference, Dehydration, HashMap, hydrate, hydrateFromTree, lookupHash } from './fact/hydrate'; import { SyncStatus, SyncStatusNotifier } from './http/web-client'; import { FactManager } from './managers/factManager'; import { User } from './model/user'; import { ObservableCollection, Observer, ResultAddedFunc } from './observer/observer'; import { SpecificationOf } from './specification/model'; import { Projection } from './specification/specification'; import { FactEnvelope, ProjectedResult } from './storage'; import { toJSON } from './util/obj'; import { Trace } from './util/trace'; export interface Profile { displayName: string; } export type MakeObservable<T> = T extends Array<infer U> ? ObservableCollection<MakeObservable<U>> : T extends { [key: string]: unknown } ? { [K in keyof T]: MakeObservable<T[K]> } : T; type WatchArgs<T extends unknown[], U> = [...T, ResultAddedFunc<MakeObservable<U>>]; export type Fact = { type: string } & HashMap; export class Jinaga { private errorHandlers: ((message: string) => void)[] = []; private loadingHandlers: ((loading: boolean) => void)[] = []; private progressHandlers: ((count: number) => void)[] = []; constructor( private authentication: Authentication, private factManager: FactManager, private syncStatusNotifier: SyncStatusNotifier | null ) { } /** * Register an callback to receive error messages. * * @param handler A function to receive error messages */ onError(handler: (message: string) => void) { this.errorHandlers.push(handler); } /** * Register a callback to receive loading state notifications. * * @param handler A function to receive loading state */ onLoading(handler: (loading: boolean) => void) { this.loadingHandlers.push(handler); } /** * Register a callback to receive outgoing fact count. * A count greater than 0 is an indication to the user that the application is saving. * * @param handler A function to receive the number of facts in the queue */ onProgress(handler: (queueCount: number) => void) { this.progressHandlers.push(handler); } onSyncStatus(handler: (status: SyncStatus) => void) { this.syncStatusNotifier?.onSyncStatus(handler); } /** * Log the user in and return a fact that represents their identity. * This method is only valid in the browser. * * @returns A promise that resolves to a fact that represents the user's identity, and the user's profile as reported by the configured Passport strategy */ async login<U extends Fact>(): Promise<{ userFact: U, profile: Profile }> { const { userFact, profile } = await this.authentication.login(); return { userFact: hydrate<U>(userFact), profile }; } /** * Access the identity of the local machine. * This method is only valid for the server and clients with local storage. * The local machine's identity is not shared with remote machines. * * @returns A promise that resolves to the local machine's identity */ async local<D extends Fact>(): Promise<D> { const deviceFact = await this.authentication.local(); return hydrate<D>(deviceFact); } /** * Creates a new fact. * This method is asynchronous. * It will be resolved when the fact has been persisted. * * @param prototype The fact to save and share * @returns The fact that was just created */ async fact<T extends Fact>(prototype: T) : Promise<T> { if (!prototype) { return prototype; } try { const fact = this.validateFact(prototype); const dehydration = new Dehydration(); const reference = dehydration.dehydrate(fact); const factRecords = dehydration.factRecords(); const hydrated = hydrateFromTree([reference], factRecords)[0]; const envelopes = factRecords.map(fact => { return <FactEnvelope>{ fact: fact, signatures: [] }; }); const authorized = await this.authentication.authorize(envelopes); const saved = await this.factManager.save(authorized); return hydrated as T; } catch (error) { this.error(error); throw error; } } /** * Execute a query for facts matching a specification. * * @param specification Use Model.given().match() to create a specification * @param given The fact or facts from which to begin the query * @returns A promise that resolves to an array of results */ async query<T extends unknown[], U>(specification: SpecificationOf<T, U>, ...given: T): Promise<U[]> { const innerSpecification = specification.specification; if (!given || given.some(g => !g)) { return []; } if (given.length !== innerSpecification.given.length) { throw new Error(`Expected ${innerSpecification.given.length} given facts, but received ${given.length}.`); } const references = given.map(g => this.prepareFactReference(g)); await this.factManager.fetch(references, innerSpecification); const projectedResults = await this.factManager.read(references, innerSpecification); const extracted = extractResults(projectedResults, innerSpecification.projection); Trace.counter("facts_loaded", extracted.totalCount); return extracted.results; } /** * Receive notification when a projection changes. * The notification function will initially receive all matching results. * It will then subsequently receive new results as they are created. * Return a function to be called when the result is removed. * * @param specification Use Model.given().match() to create a specification * @param given The fact or facts from which to begin the query * @param resultAdded A function to receive the initial and new results * @returns An observer to control notifications */ watch<T extends unknown[], U>(specification: SpecificationOf<T, U>, ...args: WatchArgs<T, U>): Observer<U> { const given: T = args.slice(0, args.length - 1) as T; const resultAdded = args[args.length - 1] as ResultAddedFunc<U>; const innerSpecification = specification.specification; if (!given) { throw new Error("No given facts provided."); } if (given.some(g => !g)) { throw new Error("One or more given facts are null."); } if (!resultAdded || typeof resultAdded !== "function") { throw new Error("No resultAdded function provided."); } if (given.length !== innerSpecification.given.length) { throw new Error(`Expected ${innerSpecification.given.length} given facts, but received ${given.length}.`); } const references = given.map(g => this.prepareFactReference(g)); return this.factManager.startObserver<U>(references, innerSpecification, resultAdded, false); } /** * Request server-sent events when a fact affects query results. * While the subscription is active, the server will push matching facts * to the client. Call Subscription.stop() to stop receiving events. * * @param specification Use Model.given().match() to create a specification * @param given The fact or facts from which to begin the subscription * @returns A subscription, which remains running until you call stop */ subscribe<T extends unknown[], U>(specification: SpecificationOf<T, U>, ...args: WatchArgs<T, U>): Observer<U> { const given: T = args.slice(0, args.length - 1) as T; const resultAdded = args[args.length - 1] as ResultAddedFunc<U>; const innerSpecification = specification.specification; if (!given) { throw new Error("No given facts provided."); } if (given.some(g => !g)) { throw new Error("One or more given facts are null."); } if (!resultAdded || typeof resultAdded !== "function") { throw new Error("No resultAdded function provided."); } if (given.length !== innerSpecification.given.length) { throw new Error(`Expected ${innerSpecification.given.length} given facts, but received ${given.length}.`); } const references = given.map(g => this.prepareFactReference(g)); return this.factManager.startObserver<U>(references, innerSpecification, resultAdded, true); } /** * Compute the SHA-256 hash of a fact. * This is a deterministic hash that can be used to identify the fact. * @param fact The fact to hash * @returns The SHA-256 hash of the fact as a base-64 string */ static hash<T extends Fact>(fact: T) { const hash = lookupHash(fact); if (hash) { return hash; } const error = this.getFactError(fact); if (error) { throw new Error(`Cannot hash the object. It is not a fact. ${error}: ${JSON.stringify(fact)}`); } const reference = dehydrateReference(fact); return reference.hash; } /** * Compute the SHA-256 hash of a fact. * This is a deterministic hash that can be used to identify the fact. * @param fact The fact to hash * @returns The SHA-256 hash of the fact as a base-64 string */ hash<T extends Fact>(fact: T) { return Jinaga.hash(fact); } /** * Purge the data store of all descendants of purge roots. * A purge root is a fact that satisfies a purge condition. * @returns Resolves when the data store has been purged. */ purge(): Promise<void> { return this.factManager.purge(); } /** * Processes the queue immediately, bypassing any delay. * This allows you to ensure that all facts have been sent to the server. */ push(): Promise<void> { return this.factManager.push(); } /** * Create some facts owned by a single-use principal. A key pair is * generated for the principal and used to sign the facts. The private * key is discarded after the facts are saved. * * @param func A function that saves a set of facts and returns one or more of them * @returns The result of the function */ async singleUse<T>(func: (principal: User) => Promise<T>): Promise<T> { try { const { last } = await this.factManager.beginSingleUse(); const principal = hydrate<User>(last); return await func(principal); } finally { this.factManager.endSingleUse(); } } private validateFact(prototype: Fact): Fact { let fact = this.removeNullPredecessors(prototype); const error = Jinaga.getFactError(fact); if (error) { throw new Error(error); } return fact as Fact; } private removeNullPredecessors(fact: HashMap): HashMap { if (!fact) { return fact; } if (fact instanceof Date) { return fact; } if (typeof fact !== 'object') { return fact; } if (Array.isArray(fact)) { // Let the validator report the error return fact; } if (lookupHash(fact)) { // If the fact has a hash symbol, then we need to retain its identity return fact; } const result: any = {}; for (const key in fact) { const value = fact[key]; if (value !== null && value !== undefined) { if (Array.isArray(value)) { result[key] = value.filter(v => v !== null && v !== undefined).map(v => this.removeNullPredecessors(v)); } else if (typeof value === 'object') { result[key] = this.removeNullPredecessors(value); } else { result[key] = value; } } } return result; } private static getFactError(prototype: HashMap): string | undefined { if (!prototype) { return 'A fact cannot be null.'; } if (!('type' in prototype)) { return 'Specify the type of the fact and all of its predecessors.'; } for (const field in prototype) { const value = toJSON(prototype[field]); if (typeof(value) === 'object') { if (Array.isArray(value)) { for (const element of value) { const error = this.getFactError(element); if (error) { return error; } } } else { const error = this.getFactError(value); if (error) { return error; } } } else if (typeof(value) === 'function') { return `A fact may not have any methods: ${field} in ${prototype.type} is a function.`; } } } private error(error: any) { Trace.error(error); this.errorHandlers.forEach((errorHandler) => { errorHandler(error); }); } private prepareFactReference(g: unknown) { const fact = JSON.parse(JSON.stringify(g)); const validatedFact = this.validateFact(fact); return dehydrateReference(validatedFact); } } function extractResults(projectedResults: ProjectedResult[], projection: Projection) { const results = []; let totalCount = 0; for (const projectedResult of projectedResults) { let result = projectedResult.result; if (projection.type === "composite") { const obj: any = {}; for (const component of projection.components) { const value = result[component.name]; if (component.type === "specification") { const { results: nestedResults, totalCount: nestedCount } = extractResults(value, component.projection); obj[component.name] = nestedResults; totalCount += nestedCount; } else { obj[component.name] = value; } } result = obj; } results.push(result); totalCount++; } return { results, totalCount }; }