UNPKG

@instantdb/core

Version:
396 lines (358 loc) • 10.2 kB
// The FrameworkClient class is a mini version of a query store that allows making queries on both the frontend and backend // you can register queries, await their results and serialize them over a server/client boundary. // The class is generic so that it can be a good starting off point to make other ssr adapters. import { coerceQuery, InstantAPIError, InstantCoreDatabase, InstantDBAttr, InstantError, weakHash, } from './index.ts'; import * as s from './store.js'; import instaql from './instaql.js'; import { RuleParams } from './schemaTypes.ts'; import { createLinkIndex } from './utils/linkIndex.ts'; export const isServer = typeof window === 'undefined' || 'Deno' in globalThis; export type FrameworkConfig = { token?: string | null; db: InstantCoreDatabase<any, any>; }; // represents an eventual result from running a query // either via ssr or by using the existing websocket connection. type QueryPromise = | { type: 'http'; triples: any; attrs: any; queryHash: any; query: any; pageInfo?: any; } | { type: 'session'; queryResult: any; }; export class FrameworkClient { private params: FrameworkConfig; private db: InstantCoreDatabase<any, any>; // stores all of the query promises so that ssr can read them // and send the relevant results alongside the html that resulted in the query resolving public resultMap: Map< string, { status: 'pending' | 'success' | 'error'; type: 'http' | 'session'; promise?: Promise<QueryPromise> | null; data?: any; error?: any; } > = new Map(); private queryResolvedCallbacks: ((result: { triples: any; attrs: any; queryHash: any; query: any; pageInfo?: any; }) => void)[] = []; constructor(params: FrameworkConfig) { this.params = params; this.db = params.db; this.resultMap = new Map< string, { type: 'http' | 'session'; status: 'pending' | 'success' | 'error'; promise?: Promise<QueryPromise>; data?: any; error?: any; } >(); } public subscribe = ( callback: (result: { triples: any; attrs: any; queryHash: string; pageInfo?: any; }) => void, ) => { this.queryResolvedCallbacks.push(callback); }; // Runs on the client when ssr gets html script tags public addQueryResult = (queryKey: string, value: any) => { this.resultMap.set(queryKey, { type: value.type, status: 'success', data: value, promise: null, error: null, }); // send the result to the client if (!isServer) { // make sure the attrs are there to create stores if (!this.db._reactor.attrs) { this.db._reactor._setAttrs(value.attrs); } this.db._reactor._addQueryData( value.query, value, !!this.db._reactor.config.schema, ); } }; public removeCachedQueryResult = (queryHash: string) => { this.resultMap.delete(queryHash); }; // Run a query on the client and return a promise with the result public queryClient = ( query_: any, opts?: { ruleParams: RuleParams }, ): Promise<QueryPromise> => { const { hash, query } = this.hashQuery(query_, opts); let resolve; let reject; const promise: Promise<QueryPromise> = new Promise( (resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise; }, ); let entry = { status: 'pending' as 'pending' | 'success' | 'error', type: 'session' as 'http' | 'session', data: undefined as any, error: undefined as any, promise: promise as any, }; let unsub: null | (() => void) = null; let unsubImmediately = false; unsub = this.db.subscribeQuery(query, (res) => { if (res.error) { entry.status = 'error'; entry.error = res.error; entry.promise = null; reject(res.error); } else { entry.status = 'success'; entry.data = res; entry.promise = null; resolve(res); } if (unsub !== null) { unsub(); } else { unsubImmediately; } }); // We may have gotten the result inside of subscribeQuery before // we defined the `unsub` function if (unsubImmediately) { unsub(); } this.resultMap.set(hash, entry); return promise; }; // creates an entry in the results map // and returns the same thing added to the map public query = ( _query: any, opts?: { ruleParams: RuleParams; }, ): { type: 'http' | 'session'; status: 'pending' | 'success' | 'error'; promise?: Promise<QueryPromise>; data?: any; error?: any; } => { const { hash, query } = this.hashQuery(_query, opts); if (this.db._reactor.status === 'authenticated') { const promise = this.db.queryOnce(_query, opts); let entry = { status: 'pending' as 'pending' | 'success' | 'error', type: 'session' as 'http' | 'session', data: undefined as any, error: undefined as any, promise: promise as any, }; promise.then((result) => { entry.status = 'success'; entry.data = result; entry.promise = null; }); promise.catch((error) => { entry.status = 'error'; entry.error = error; entry.promise = null; }); this.resultMap.set(hash, entry); return entry as any; } const promise = this.getTriplesAndAttrsForQuery(query); let entry = { status: 'pending' as 'pending' | 'success' | 'error', type: 'http' as 'http' | 'session', data: undefined as any, error: undefined as any, promise: promise as any, }; promise.then((result) => { entry.status = 'success'; entry.data = result; entry.promise = null; }); promise.catch((error) => { entry.status = 'error'; entry.error = error; entry.promise = null; }); promise.then((result) => { this.queryResolvedCallbacks.forEach((callback) => { callback({ queryHash: hash, query: query, attrs: result.attrs, triples: result.triples, pageInfo: result.pageInfo, }); }); }); this.resultMap.set(hash, entry); return entry; }; public getExistingResultForQuery = ( _query: any, opts?: { ruleParams: RuleParams; }, ) => { const { hash } = this.hashQuery(_query, opts); return this.resultMap.get(hash); }; // creates a query result from a set of triples, query, and attrs // can be run server side or client side public completeIsomorphic = ( query: any, triples: any[], attrs: InstantDBAttr[], pageInfo?: any, ) => { const attrMap = {}; attrs.forEach((attr) => { attrMap[attr.id] = attr; }); const enableCardinalityInference = Boolean(this.db?._reactor?.config?.schema) && ('cardinalityInference' in this.db?._reactor?.config ? Boolean(this.db?._reactor.config?.cardinalityInference) : true); const attrsStore = new s.AttrsStoreClass( attrs.reduce((acc, attr) => { acc[attr.id] = attr; return acc; }, {}), createLinkIndex(this.db?._reactor.config.schema), ); const store = s.createStore( attrsStore, triples, enableCardinalityInference, this.params.db._reactor.config.useDateObjects || false, ); const resp = instaql( { store: store, attrsStore: attrsStore, pageInfo: pageInfo, aggregate: undefined, }, query, ); return resp; }; public hashQuery = ( _query: any, opts?: { ruleParams: RuleParams; }, ): { hash: string; query: any } => { if (_query && opts && 'ruleParams' in opts) { _query = { $$ruleParams: opts['ruleParams'], ..._query }; } const query = _query ? coerceQuery(_query) : null; return { hash: weakHash(query), query: query }; }; // Run by the server to get triples and attrs public getTriplesAndAttrsForQuery = async ( query: any, ): Promise<{ triples: any[]; attrs: InstantDBAttr[]; query: any; queryHash: string; type: 'http'; pageInfo?: any; }> => { try { const response = await fetch( `${this.db._reactor.config.apiURI}/runtime/framework/query`, { method: 'POST', headers: { 'app-id': this.params.db._reactor.config.appId, 'Content-Type': 'application/json', Authorization: this.params.token ? `Bearer ${this.params.token}` : undefined, } as Record<string, string>, body: JSON.stringify({ query: query, }), }, ); if (!response.ok) { try { const data = await response.json(); if ('message' in data) { throw new InstantAPIError({ body: data, status: response.status }); } else { throw new Error('Error getting triples from server'); } } catch (e) { if (e instanceof InstantError) { throw e; } throw new Error('Error getting triples from server'); } } const data = await response.json(); const attrs = data?.attrs; if (!attrs) { throw new Error('No attrs'); } // TODO: make safer const triples = data.result?.[0].data?.['datalog-result']?.['join-rows'][0]; const pageInfo = data.result?.[0]?.data?.['page-info']; return { attrs, triples, type: 'http', queryHash: this.hashQuery(query).hash, query, pageInfo, }; } catch (err: any) { if (err instanceof InstantError) { throw err; } const errWithMessage = new Error( 'Error getting triples from framework client', ); errWithMessage.cause = err; throw errWithMessage; } }; }