@instantdb/core
Version:
Instant's core local abstraction
396 lines (358 loc) • 10.2 kB
text/typescript
// 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;
}
};
}