import { CollectionNameFromModels, createUpdateProxyAndTrackChanges, deserializeEntity, deserializeFetchResult, EntityNotFoundError, FetchResult, Models, queryBuilder, ReadModel, SchemaQuery, serializeEntity, TriplitError, Type, UpdatePayload, WriteModel, } from '@triplit/db'; function parseError(error: string) { try { const jsonError = JSON.parse(error); return TriplitError.fromJson(jsonError); } catch (e) { return new TriplitError(`Failed to parse remote error response: ${error}`); } } export type HttpClientOptions = Models> = { serverUrl?: string; token?: string; schema?: M; schemaFactory?: () => M | Promise; }; // Interact with remote via http api, totally separate from your local database export class HttpClient = Models> { constructor(private options: HttpClientOptions = {}) {} private _schemaInitialized: boolean = false; // Hack: use schemaFactory to get schema if it's not ready from provider private async schema(): Promise { if (!this._schemaInitialized) { if (!this.options.schema) { this.options.schema = await (this.options.schemaFactory ? this.options.schemaFactory() : undefined); } this._schemaInitialized = true; } return this.options.schema; } updateOptions(options: HttpClientOptions) { this.options = { ...this.options, ...options }; } private async sendRequest( uri: string, method: string, body: any, options: { isFile?: boolean } = { isFile: false } ) { const serverUrl = this.options.serverUrl; if (!serverUrl) throw new TriplitError('No server url provided'); if (!this.options.token) throw new TriplitError('No token provided'); const headers: HeadersInit = { Authorization: 'Bearer ' + this.options.token, 'Content-Type': 'application/json', }; const stringifiedBody = JSON.stringify(body); let form; if (options.isFile) { form = new FormData(); form.append('data', stringifiedBody); delete headers['Content-Type']; } const res = await fetch(serverUrl + uri, { method, headers, body: options.isFile ? form : stringifiedBody, }); if (!res.ok) return { data: undefined, error: parseError(await res.text()) }; return { data: await res.json(), error: undefined }; } async fetch>( query: Q ): Promise> { const { data, error } = await this.sendRequest('/fetch', 'POST', { query, }); if (error) throw error; return deserializeFetchResult( query, await this.schema(), data ) as FetchResult; } async fetchOne>( query: Q ): Promise> { query = { ...query, limit: 1 }; const { data, error } = await this.sendRequest('/fetch', 'POST', { query, }); if (error) throw error; const deserialized = deserializeFetchResult( query, await this.schema(), data ); const entity = deserialized[0]; if (!entity) return null; return entity as NonNullable>; } async fetchById>( collectionName: CN, id: string ): Promise> { const query = this.query(collectionName).Id(id); return this.fetchOne<{ collectionName: CN }>(query); } async insert>( collectionName: CN, object: WriteModel ): Promise> { // we need to convert Sets to arrays before sending to the server const schema = await this.schema(); const collectionSchema = schema?.[collectionName].schema; // TODO: we should just be able to use the internal changeset here, which is // already JSON compliant const jsonEntity = collectionSchema ? Type.serialize(collectionSchema, object, 'decoded') : object; const { data, error } = await this.sendRequest('/insert', 'POST', { collectionName, entity: jsonEntity, }); if (error) throw error; return deserializeEntity(collectionSchema, data); } async bulkInsert(bulk: BulkInsert): Promise> { const schema = await this.schema(); let payload = bulk; if (schema) { const schemaPayload: BulkInsert = {}; for (const key in bulk) { const collectionName = key as CollectionNameFromModels; const data = bulk[collectionName]; const collectionSchema = schema?.[collectionName].schema; if (!data) continue; schemaPayload[collectionName] = data.map((entity: any) => serializeEntity(collectionSchema, entity) ); } payload = schemaPayload; } const { data, error } = await this.sendRequest( '/bulk-insert-file', 'POST', payload, { isFile: true } ); if (error) throw error; const result: BulkInsertResult = {}; for (const key in data) { const collectionName = key as CollectionNameFromModels; const collectionSchema = schema?.[collectionName].schema; result[collectionName] = data[key].map((entity: any) => deserializeEntity(collectionSchema, entity) ); } return result; } async update>( collectionName: CN, id: string, update: UpdatePayload ) { let changes = undefined; const schema = await this.schema(); const collectionSchema = schema?.[collectionName]?.schema; if (typeof update === 'function') { const existingEntity = await this.fetchById(collectionName, id); if (!existingEntity) { throw new EntityNotFoundError(id, collectionName); } changes = {}; // one of the key assumptions we're making here is that the update proxy // will take car of the conversion of Sets and Dates. This is mostly // to account for capturing changes to Sets because we need something // that can track deletes and sets to a Set, which a Set itself cannot do await update( createUpdateProxyAndTrackChanges( existingEntity, changes, collectionSchema ) ); } else { changes = update; } changes = collectionSchema ? Type.encode(collectionSchema, changes) : changes; const { data, error } = await this.sendRequest('/update', 'POST', { collectionName, entityId: id, changes, }); if (error) throw error; return data; } async delete>( collectionName: CN, entityId: string ) { const { data, error } = await this.sendRequest('/delete', 'POST', { collectionName, entityId, }); if (error) throw error; return data; } async deleteAll>(collectionName: CN) { const { data, error } = await this.sendRequest('/delete-all', 'POST', { collectionName, }); if (error) throw error; return data; } query>(collectionName: CN) { return queryBuilder(collectionName); } } export type BulkInsert = Models> = { [CN in CollectionNameFromModels]?: WriteModel[]; }; export type BulkInsertResult = Models> = { [CN in CollectionNameFromModels]?: ReadModel[]; };