@triplit/client
Version:
259 lines (235 loc) • 7.61 kB
text/typescript
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<M extends Models<M> = Models> = {
serverUrl?: string;
token?: string;
schema?: M;
schemaFactory?: () => M | Promise<M>;
};
// Interact with remote via http api, totally separate from your local database
export class HttpClient<M extends Models<M> = Models> {
constructor(private options: HttpClientOptions<M> = {}) {}
private _schemaInitialized: boolean = false;
// Hack: use schemaFactory to get schema if it's not ready from provider
private async schema(): Promise<M | undefined> {
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<M>) {
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<Q extends SchemaQuery<M>>(
query: Q
): Promise<FetchResult<M, Q, 'many'>> {
const { data, error } = await this.sendRequest('/fetch', 'POST', {
query,
});
if (error) throw error;
return deserializeFetchResult(
query,
await this.schema(),
data
) as FetchResult<M, Q, 'many'>;
}
async fetchOne<Q extends SchemaQuery<M>>(
query: Q
): Promise<FetchResult<M, Q, 'one'>> {
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<FetchResult<M, Q, 'one'>>;
}
async fetchById<CN extends CollectionNameFromModels<M>>(
collectionName: CN,
id: string
): Promise<FetchResult<M, { collectionName: CN }, 'one'>> {
const query = this.query(collectionName).Id(id);
return this.fetchOne<{ collectionName: CN }>(query);
}
async insert<CN extends CollectionNameFromModels<M>>(
collectionName: CN,
object: WriteModel<M, CN>
): Promise<ReadModel<M, CN>> {
// 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<M>): Promise<BulkInsertResult<M>> {
const schema = await this.schema();
let payload = bulk;
if (schema) {
const schemaPayload: BulkInsert<M> = {};
for (const key in bulk) {
const collectionName = key as CollectionNameFromModels<M>;
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<M> = {};
for (const key in data) {
const collectionName = key as CollectionNameFromModels<M>;
const collectionSchema = schema?.[collectionName].schema;
result[collectionName] = data[key].map((entity: any) =>
deserializeEntity(collectionSchema, entity)
);
}
return result;
}
async update<CN extends CollectionNameFromModels<M>>(
collectionName: CN,
id: string,
update: UpdatePayload<M, CN>
) {
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<CN extends CollectionNameFromModels<M>>(
collectionName: CN,
entityId: string
) {
const { data, error } = await this.sendRequest('/delete', 'POST', {
collectionName,
entityId,
});
if (error) throw error;
return data;
}
async deleteAll<CN extends CollectionNameFromModels<M>>(collectionName: CN) {
const { data, error } = await this.sendRequest('/delete-all', 'POST', {
collectionName,
});
if (error) throw error;
return data;
}
query<CN extends CollectionNameFromModels<M>>(collectionName: CN) {
return queryBuilder<M, CN>(collectionName);
}
}
export type BulkInsert<M extends Models<M> = Models> = {
[CN in CollectionNameFromModels<M>]?: WriteModel<M, CN>[];
};
export type BulkInsertResult<M extends Models<M> = Models> = {
[CN in CollectionNameFromModels<M>]?: ReadModel<M, CN>[];
};