typesaurus
Version:
Type-safe ODM for Firestore
757 lines (751 loc) • 24.2 kB
JavaScript
;
exports.UpdateField = exports.Ref = exports.Doc = exports.Collection = void 0;
exports._query = _query;
exports.all = all;
exports.assertEnvironment = assertEnvironment;
exports.pathRegExp = exports.nativeSymbol = exports.dbSymbol = void 0;
exports.pathToDoc = pathToDoc;
exports.pathToRef = pathToRef;
exports.queryHelpers = queryHelpers;
exports.refToFirestoreDocument = refToFirestoreDocument;
exports.schema = schema;
exports.unwrapData = unwrapData;
exports.updateFields = updateFields;
exports.updateHelpers = updateHelpers;
exports.wrapData = wrapData;
exports.writeData = writeData;
exports.writeHelpers = writeHelpers;
var _firestore = require("firebase/firestore");
var _index = require("../../sp/index.js");
var _firebase = require("./firebase.js");
/**
* The symbol allows to access native Firestore reference/query object from
* the subscription promise. It enables advanced integrations like
* the Typesaurus Point-in-Time Recovery adapter.
*/
const nativeSymbol = exports.nativeSymbol = Symbol("native");
/**
* The symbol allows to access the Typesaurus database instance.
*/
const dbSymbol = exports.dbSymbol = Symbol("db");
function schema(getSchema, options) {
let firestore;
const schema = getSchema(schemaHelpers());
return db(() => firestore = firestore || (0, _firebase.firestore)(options), schema);
}
class Collection {
constructor(db, name, path) {
this.db = db;
this.firestore = db[_firebase.firestoreSymbol];
this.type = "collection";
this.name = name;
this.path = path;
this.update = (id, data, options) => {
assertEnvironment(options?.as);
const updateData = typeof data === "function" ? data(updateHelpers()) : data;
if (!updateData) return;
const update = Array.isArray(updateData) ? updateFields(updateData) : updateData instanceof UpdateField ? updateFields([updateData]) : updateData;
return (0, _firestore.updateDoc)(this.firebaseDoc(id), unwrapData(this.firestore, update)).then(() => this.ref(id));
};
this.update.build = (id, options) => {
assertEnvironment(options?.as);
const fields = [];
return {
...updateHelpers("build", fields),
run: () => (0, _firestore.updateDoc)(this.firebaseDoc(id), unwrapData(this.firestore, updateFields(fields))).then(() => this.ref(id))
};
};
this.query = (queries, options) => {
assertEnvironment(options?.as);
const queriesResult = queries(queryHelpers());
if (!queriesResult) return;
return _query(this.firestore, this.adapter(), [].concat(queriesResult).filter(q => !!q));
};
this.query.build = options => {
assertEnvironment(options?.as);
const queries = [];
return {
...queryHelpers("builder", queries),
run: () => _query(this.firestore, this.adapter(), queries)
};
};
}
id(id) {
if (id) return id;else return Promise.resolve((0, _firestore.doc)(this.firebaseCollection()).id);
}
ref(id) {
return new Ref(this, id);
}
doc(id, value, options) {
if (!value && "id" in id && "data" in id && typeof id.data === "function") {
const data = id.data();
if (data) return this.doc(id.id, wrapData(this.db, data));else return null;
} else {
assertEnvironment(options?.as);
return new Doc(this, id, value);
}
}
add(data, options) {
assertEnvironment(options?.as);
return (0, _firestore.addDoc)(this.firebaseCollection(), writeData(this.firestore, data)).then(firebaseRef => this.ref(firebaseRef.id));
}
set(id, data, options) {
assertEnvironment(options?.as);
return (0, _firestore.setDoc)(this.firebaseDoc(id), writeData(this.firestore, data)).then(() => this.ref(id));
}
upset(id, data, options) {
assertEnvironment(options?.as);
return (0, _firestore.setDoc)(this.firebaseDoc(id), writeData(this.firestore, data), {
merge: true
}).then(() => this.ref(id));
}
remove(id) {
return (0, _firestore.deleteDoc)(this.firebaseDoc(id)).then(() => this.ref(id));
}
all(options) {
assertEnvironment(options?.as);
return all(this.adapter());
}
get(id, options) {
assertEnvironment(options?.as);
const doc = this.firebaseDoc(id);
return new _index.SubscriptionPromise({
request: request({
[nativeSymbol]: doc,
[dbSymbol]: this.db,
kind: "get",
path: this.path,
id
}),
get: async () => {
const firebaseSnap = await (0, _firestore.getDoc)(doc);
const data = firebaseSnap.data();
if (data) return new Doc(this, id, wrapData(this.db, data));
return null;
},
subscribe: (onResult, onError) => (0, _firestore.onSnapshot)(doc, firebaseSnap => {
const data = firebaseSnap.data();
if (data) onResult(new Doc(this, id, wrapData(this.db, data)));else onResult(null);
}, onError)
});
}
many(ids, options) {
assertEnvironment(options?.as);
const docs = ids.map(id => this.firebaseDoc(id));
return new _index.SubscriptionPromise({
request: request({
[nativeSymbol]: docs,
[dbSymbol]: this.db,
kind: "many",
path: this.path,
ids
}),
get: () => Promise.all(ids.map(id => this.get(id))),
subscribe: (onResult, onError) => {
// Firestore#getAll doesn't like empty lists
if (ids.length === 0) {
onResult([]);
return () => {};
}
let waiting = ids.length;
const result = new Array(ids.length);
const offs = ids.map((id, idIndex) => this.get(id).on(doc => {
result[idIndex] = doc;
if (waiting) waiting--;
if (waiting === 0) onResult(result);
}).catch(onError));
return () => offs.map(off => off());
}
});
}
async count() {
const snap = await (0, _firestore.getCountFromServer)(this.firebaseCollection());
return snap.data().count;
}
async sum(field) {
const snap = await (0, _firestore.getAggregateFromServer)(this.firebaseCollection(), {
result: (0, _firestore.sum)(field)
});
return snap.data().result;
}
async average(field) {
const snap = await (0, _firestore.getAggregateFromServer)(this.firebaseCollection(), {
result: (0, _firestore.average)(field)
});
return snap.data().result;
}
adapter() {
return {
db: () => this.db,
collection: () => this.firebaseCollection(),
doc: snapshot => new Doc(this, snapshot.id, wrapData(this.db, snapshot.data())),
request: () => ({
path: this.path
})
};
}
firebaseCollection() {
return (0, _firestore.collection)(this.firestore(), this.path);
}
firebaseDoc(id) {
return (0, _firestore.doc)(this.firestore(), this.path, id);
}
as() {
return this;
}
}
exports.Collection = Collection;
class Ref {
constructor(collection, id) {
this.type = "ref";
this.collection = collection;
this.id = id;
this.update = (data, options) => this.collection.update(this.id, data, options);
this.update.build = options => this.collection.update.build(this.id, options);
}
get(options) {
return this.collection.get(this.id, options);
}
set(data, options) {
return this.collection.set(this.id, data, options);
}
upset(data, options) {
return this.collection.upset(this.id, data, options);
}
async remove() {
return this.collection.remove(this.id);
}
as() {
return this;
}
}
exports.Ref = Ref;
class Doc {
constructor(collection, id, data) {
this.type = "doc";
this.collection = collection;
this.ref = new Ref(collection, id);
this.data = data;
this.props = {
environment: "client",
source: "database",
// TODO
dateStrategy: "none",
// TODO
pendingWrites: false // TODO
};
this.update = (data, options) => this.ref.update(data, options);
this.update.build = options => this.ref.update.build(options);
}
get(options) {
return this.ref.get(options);
}
set(data, options) {
return this.ref.set(data, options);
}
upset(data, options) {
return this.ref.upset(data, options);
}
remove() {
return this.ref.remove();
}
test(props) {
return Object.entries(props).every(([key, value]) => this.props[key] === value);
}
narrow(cb) {
const result = cb(this.data);
if (result) return this;
}
as() {
return this;
}
}
exports.Doc = Doc;
function all(adapter) {
const firebaseCollection = adapter.collection();
return new _index.SubscriptionPromise({
request: request({
[nativeSymbol]: firebaseCollection,
[dbSymbol]: adapter.db(),
kind: "all",
...adapter.request()
}),
get: async () => {
const snapshot = await (0, _firestore.getDocs)(firebaseCollection);
return snapshot.docs.map(doc => adapter.doc(doc));
},
subscribe: (onResult, onError) => (0, _firestore.onSnapshot)(firebaseCollection, firebaseSnap => {
const docs = firebaseSnap.docs.map(doc => adapter.doc(doc));
const changes = () => firebaseSnap.docChanges().map(change => ({
type: change.type,
oldIndex: change.oldIndex,
newIndex: change.newIndex,
doc: docs[change.type === "removed" ? change.oldIndex : change.newIndex] ||
// If change.type indicates 'removed', sometimes (not all the time) `docs` does not
// contain the removed document. In that case, we'll restore it from `change.doc`:
adapter.doc(change.doc
// {
// firestoreData: true,
// environment: a.environment,
// serverTimestamps: options?.serverTimestamps,
// ...a.getDocMeta(change.doc)
// }
)
}));
const meta = {
changes,
size: firebaseSnap.size,
empty: firebaseSnap.empty
};
onResult(docs, meta);
}, onError)
});
}
function writeData(firestore, data) {
return unwrapData(firestore, typeof data === "function" ? data(writeHelpers()) : data);
}
function writeHelpers() {
return {
serverDate: () => ({
type: "value",
kind: "serverDate"
}),
remove: () => ({
type: "value",
kind: "remove"
}),
increment: number => ({
type: "value",
kind: "increment",
number
}),
arrayUnion: values => ({
type: "value",
kind: "arrayUnion",
values: [].concat(values)
}),
arrayRemove: values => ({
type: "value",
kind: "arrayRemove",
values: [].concat(values)
})
};
}
function updateFields(fields) {
return fields.reduce((acc, field) => {
if (!field) return acc;
const {
key,
value
} = field;
acc[Array.isArray(key) ? key.join(".") : key] = value;
return acc;
}, {});
}
class UpdateField {
constructor(key, value) {
this.key = key;
this.value = value;
}
}
exports.UpdateField = UpdateField;
function updateHelpers(mode = "helpers", acc) {
function processField(value) {
if (mode === "helpers") {
return value;
} else {
// Builder mode
acc.push(value);
}
}
return {
...writeHelpers(),
field: (...field) => ({
set: value => processField(new UpdateField(field, value))
})
};
}
function schemaHelpers() {
return {
collection() {
return {
type: "collection",
sub(schema) {
return {
type: "collection",
schema
};
},
name(name) {
return {
type: "collection",
name,
sub(schema) {
return {
type: "collection",
name,
schema
};
}
};
}
};
}
};
}
function db(firestore, schema, nestedPath) {
const enrichedSchema = {
[_firebase.firestoreSymbol]: firestore
};
return Object.entries(schema).reduce((enrichedSchema, [collectionName, plainCollection]) => {
const name = typeof plainCollection.name === "string" ? plainCollection.name : collectionName;
const collection = new Collection(enrichedSchema, name, nestedPath ? `${nestedPath}/${name}` : name);
if ("schema" in plainCollection) {
enrichedSchema[collectionName] = new Proxy(() => {}, {
get: (_target, prop) => {
if (prop === "schema") return plainCollection.schema;else if (prop === "sub") return subShortcut(firestore, plainCollection.schema);else return collection[prop];
},
has(_target, prop) {
return prop in plainCollection;
},
apply: (_target, _prop, [id]) => db(firestore, plainCollection.schema, `${collection.path}/${id}`)
});
} else {
enrichedSchema[collectionName] = collection;
}
return enrichedSchema;
}, enrichedSchema);
}
function subShortcut(firestore, schema) {
return Object.entries(schema).reduce((shortcutsSchema, [path, schemaCollection]) => {
shortcutsSchema[path] = {
id(id) {
if (id) return id;else return Promise.resolve((0, _firestore.doc)((0, _firestore.collection)(firestore(), "any")).id);
}
};
if ("schema" in schemaCollection) shortcutsSchema[path].sub = subShortcut(firestore, schemaCollection.schema);
return shortcutsSchema;
}, {});
}
function _query(firestore, adapter, queries) {
const firebaseWhereQueries = [];
const firebaseRestQueries = [];
let cursors = [];
queries.forEach(query => {
switch (query.type) {
case "order":
{
const {
field,
method,
cursors: queryCursors
} = query;
firebaseRestQueries.push((0, _firestore.orderBy)(field[0] === "__id__" ? (0, _firestore.documentId)() : field.join("."), method));
if (queryCursors) cursors = cursors.concat(queryCursors.reduce((acc, cursor) => {
if (!cursor) return acc;
const {
type,
position,
value
} = cursor;
return acc.concat({
type,
position,
value: typeof value === "object" && value !== null && "type" in value && value.type == "doc" ? field[0] === "__id__" ? value.ref.id : field.reduce((acc, key) => acc[key], value.data) : value
});
}, []));
break;
}
case "where":
{
const {
field,
filter,
value
} = query;
firebaseWhereQueries.push((0, _firestore.where)(wherePath(field), filter, unwrapData(firestore, value)));
break;
}
case "limit":
{
firebaseRestQueries.push((0, _firestore.limit)(query.number));
break;
}
case "or":
{
if (!query.queries.length) break;
firebaseWhereQueries.push((0, _firestore.or)(...query.queries.map(q => (0, _firestore.where)(wherePath(q.field), q.filter, unwrapData(firestore, q.value)))));
break;
}
}
}, []);
let groupedCursors = [];
cursors.forEach(cursor => {
let methodValues = groupedCursors.find(([position]) => position === cursor.position);
if (!methodValues) {
methodValues = [cursor.position, []];
groupedCursors.push(methodValues);
}
methodValues[1].push(unwrapData(firestore, cursor.value));
});
const firebaseCursors = [];
if (cursors.length && cursors.every(cursor => cursor.value !== undefined)) groupedCursors.forEach(([method, values]) => {
firebaseCursors.push((method === "startAt" ? _firestore.startAt : method === "startAfter" ? _firestore.startAfter : method === "endAt" ? _firestore.endAt : _firestore.endBefore)(...values));
});
const firebaseQuery = () => (0, _firestore.query)(adapter.collection(), (0, _firestore.and)(...firebaseWhereQueries), ...firebaseRestQueries, ...firebaseCursors);
const sp = new _index.SubscriptionPromise({
request: request({
get [nativeSymbol]() {
return firebaseQuery();
},
[dbSymbol]: adapter.db(),
kind: "query",
...adapter.request(),
queries: queries
}),
get: async () => {
const firebaseSnap = await (0, _firestore.getDocs)(firebaseQuery());
return firebaseSnap.docs.map(firebaseSnap => adapter.doc(firebaseSnap
// {
// firestoreData: true,
// environment: a.environment as Environment,
// serverTimestamps: options?.serverTimestamps,
// ...a.getDocMeta(firebaseSnap)
// }
));
},
subscribe: (onResult, onError) => {
let q;
try {
q = firebaseQuery();
} catch (error) {
onError(error);
return;
}
return (0, _firestore.onSnapshot)(q, firebaseSnap => {
const docs = firebaseSnap.docs.map(firebaseSnap => adapter.doc(firebaseSnap
// {
// firestoreData: true,
// environment: a.environment as Environment,
// serverTimestamps: options?.serverTimestamps,
// ...a.getDocMeta(firebaseSnap)
// }
));
const changes = () => firebaseSnap.docChanges().map(change => ({
type: change.type,
oldIndex: change.oldIndex,
newIndex: change.newIndex,
doc: docs[change.type === "removed" ? change.oldIndex : change.newIndex] ||
// If change.type indicates 'removed', sometimes (not all the time) `docs` does not
// contain the removed document. In that case, we'll restore it from `change.doc`:
adapter.doc(change.doc
// {
// firestoreData: true,
// environment: a.environment,
// serverTimestamps: options?.serverTimestamps,
// ...a.getDocMeta(change.doc)
// }
)
}));
const meta = {
changes,
size: firebaseSnap.size,
empty: firebaseSnap.empty
};
onResult(docs, meta);
}, onError);
}
});
Object.assign(sp, {
count: async () => {
const snap = await (0, _firestore.getCountFromServer)(firebaseQuery());
return snap.data().count;
},
sum: async field => {
const snap = await (0, _firestore.getAggregateFromServer)(firebaseQuery(), {
result: (0, _firestore.sum)(field)
});
return snap.data().result;
},
average: async field => {
const snap = await (0, _firestore.getAggregateFromServer)(firebaseQuery(), {
result: (0, _firestore.average)(field)
});
return snap.data().result;
}
});
return sp;
}
function wherePath(field) {
return field[0] === "__id__" ? (0, _firestore.documentId)() : field.join(".");
}
function queryHelpers(mode = "helpers", acc) {
function processQuery(value) {
if (mode === "builder") {
// Push query to the list, to run them later
acc.push(value);
// Remove nested or queries because they get added to the acc but we want
// to process them separately.
if (value.type === "or") for (let index = acc.length - 1; index >= 0; index--) if (value.queries.includes(acc[index])) acc.splice(index, 1);
}
return value;
}
function where(field, filter, value) {
return processQuery({
type: "where",
field,
filter,
value
});
}
return {
field: (...field) => ({
lt: where.bind(null, field, "<"),
lte: where.bind(null, field, "<="),
eq: where.bind(null, field, "=="),
not: where.bind(null, field, "!="),
gt: where.bind(null, field, ">"),
gte: where.bind(null, field, ">="),
in: where.bind(null, field, "in"),
notIn: where.bind(null, field, "not-in"),
contains: where.bind(null, field, "array-contains"),
containsAny: where.bind(null, field, "array-contains-any"),
order: (maybeMethod, maybeCursors) => processQuery({
type: "order",
field,
method: typeof maybeMethod === "string" ? maybeMethod : "asc",
cursors: maybeCursors ? [].concat(maybeCursors) : maybeMethod && typeof maybeMethod !== "string" ? [].concat(maybeMethod) : undefined
})
}),
limit: number => processQuery({
type: "limit",
number
}),
startAt: value => ({
type: "cursor",
position: "startAt",
value
}),
startAfter: value => ({
type: "cursor",
position: "startAfter",
value
}),
endAt: value => ({
type: "cursor",
position: "endAt",
value
}),
endBefore: value => ({
type: "cursor",
position: "endBefore",
value
}),
docId: () => "__id__",
or: (...queries) => processQuery({
type: "or",
queries
})
};
}
/**
* Creates Firestore document from a reference.
*
* @param ref - The reference to create Firestore document from
* @returns Firestore document
*/
function refToFirestoreDocument(firestore, ref) {
return (0, _firestore.doc)(firestore(), ref.collection.path, ref.id);
}
const pathRegExp = exports.pathRegExp = /^(?:(.+\/)?(.+))\/(.+)$/;
/**
* Creates a reference from a Firestore path.
*
* @param path - The Firestore path
* @returns Reference to a document
*/
function pathToRef(db, path) {
const captures = path.match(pathRegExp);
if (!captures) throw new Error(`Can't parse path ${path}`);
const [, nestedPath, name, id] = captures;
return new Ref(new Collection(db, name, (nestedPath || "") + name), id);
}
function pathToDoc(db, path, data) {
const captures = path.match(pathRegExp);
if (!captures) throw new Error(`Can't parse path ${path}`);
const [, nestedPath, name, id] = captures;
return new Doc(new Collection(db, name, (nestedPath || "") + name), id, data);
}
/**
* Converts Typesaurus data to Firestore format. It deeply traverse all the data and
* converts values to compatible format.
*
* @param data - the data to convert
* @returns the data in Firestore format
*/
function unwrapData(firestore, data) {
if (data && typeof data === "object") {
if (data.type === "ref") {
return refToFirestoreDocument(firestore, data);
} else if (data.type === "value") {
const fieldValue = data;
switch (fieldValue.kind) {
case "remove":
return (0, _firestore.deleteField)();
case "increment":
return (0, _firestore.increment)(fieldValue.number);
case "arrayUnion":
return (0, _firestore.arrayUnion)(...unwrapData(firestore, fieldValue.values));
case "arrayRemove":
return (0, _firestore.arrayRemove)(...unwrapData(firestore, fieldValue.values));
case "serverDate":
return (0, _firestore.serverTimestamp)();
}
} else if (data instanceof Date) {
return _firestore.Timestamp.fromDate(data);
}
const isArray = Array.isArray(data);
const unwrappedObject = Object.assign(isArray ? [] : {}, data);
Object.keys(unwrappedObject).forEach(key => {
unwrappedObject[key] = unwrapData(firestore, unwrappedObject[key]);
});
return unwrappedObject;
} else if (data === undefined) {
return null;
} else {
return data;
}
}
/**
* Converts Firestore data to Typesaurus format. It deeply traverse all the
* data and converts values to compatible format.
*
* @param data - the data to convert
* @returns the data in Typesaurus format
*/
function wrapData(db, data, ref = pathToRef) {
if (data instanceof _firestore.DocumentReference) {
return ref(db, data.path);
} else if (data instanceof _firestore.Timestamp) {
return data.toDate();
} else if (data && typeof data === "object") {
const wrappedData = Object.assign(Array.isArray(data) ? [] : {}, data);
Object.keys(wrappedData).forEach(key => {
wrappedData[key] = wrapData(db, wrappedData[key], ref);
});
return wrappedData;
} else if (typeof data === "string" && data === "%%undefined%%") {
return undefined;
} else {
return data;
}
}
function assertEnvironment(environment) {
if (environment && environment !== "client") throw new Error(`Expected ${environment} environment`);
}
function request(payload) {
return {
type: "request",
...payload
};
}