cosmic-database
Version:
A database library for cosmic.new. Designed to be used and deployed on cosmic.new
410 lines (409 loc) • 15 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.db = exports.CosmicDatabase = exports.GeoPoint = exports.Timestamp = exports.FieldValue = exports.CosmicCollectionReference = exports.CosmicQuery = exports.WriteBatch = exports.DocumentReference = exports.QuerySnapshot = exports.DocumentSnapshot = void 0;
const API_BASE_URL = process.env.COSMIC_DATABASE_API_URL || 'https://api.cosmic.new/database';
const API_SECRET = process.env.COSMIC_DATABASE_SECRET;
if (!API_SECRET) {
throw new Error('❌ COSMIC DATABASE ERROR: COSMIC_DATABASE_SECRET environment variable is required');
}
const COMPOSITE_INDEX_ERROR = "❌ COSMIC DATABASE ERROR: Composite indexes are forbidden in Cosmic Database. This query would require a composite index.";
const MULTIPLE_WHERE_ERROR = "❌ COSMIC DATABASE ERROR: Multiple where clauses on different fields require composite indexes and are forbidden.";
const MULTIPLE_ORDER_BY_ERROR = "❌ COSMIC DATABASE ERROR: Multiple orderBy clauses require composite indexes and are forbidden.";
const WHERE_WITH_ORDER_BY_ERROR = "❌ COSMIC DATABASE ERROR: Combining where clauses with orderBy on different fields requires composite indexes and is forbidden.";
async function apiCall(endpoint, options = {}) {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
'X-Cosmic-Database-Secret': API_SECRET,
'X-Client-ID': process.env.NEXT_PUBLIC_CLIENT_ID || '',
...options.headers,
},
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Cosmic Database API Error: ${error}`);
}
return response.json();
}
class DocumentSnapshot {
constructor(id, ref, data) {
this._id = id;
this._ref = ref;
this._data = data;
this._exists = data !== undefined;
}
get id() {
return this._id;
}
get ref() {
return this._ref;
}
get exists() {
return this._exists;
}
data() {
return this._data;
}
get(fieldPath) {
if (!this._data)
return undefined;
const fields = fieldPath.split('.');
let value = this._data;
for (const field of fields) {
value = value?.[field];
}
return value;
}
}
exports.DocumentSnapshot = DocumentSnapshot;
class QuerySnapshot {
constructor(docs) {
this._docs = docs;
this._size = docs.length;
}
get docs() {
return this._docs;
}
get size() {
return this._size;
}
get empty() {
return this._size === 0;
}
forEach(callback) {
this._docs.forEach(callback);
}
}
exports.QuerySnapshot = QuerySnapshot;
class DocumentReference {
constructor(path, parent) {
this._path = path;
const parts = path.split('/');
this._id = parts[parts.length - 1];
this._parent = parent;
}
get id() {
return this._id;
}
get path() {
return this._path;
}
get parent() {
return this._parent;
}
async get() {
const data = await apiCall(`/document/${encodeURIComponent(this._path)}`);
return new DocumentSnapshot(this._id, this, data.exists ? data.data : undefined);
}
async set(data, options) {
await apiCall(`/document/${encodeURIComponent(this._path)}`, {
method: 'PUT',
body: JSON.stringify({ data, options }),
});
}
async update(data) {
await apiCall(`/document/${encodeURIComponent(this._path)}`, {
method: 'PATCH',
body: JSON.stringify({ data }),
});
}
async delete() {
await apiCall(`/document/${encodeURIComponent(this._path)}`, {
method: 'DELETE',
});
}
collection(collectionPath) {
return new CosmicCollectionReference(`${this._path}/${collectionPath}`);
}
}
exports.DocumentReference = DocumentReference;
class WriteBatch {
constructor() {
this.operations = [];
}
set(ref, data, options) {
this.operations.push({
type: 'set',
path: ref.path,
data,
options,
});
return this;
}
update(ref, data) {
this.operations.push({
type: 'update',
path: ref.path,
data,
});
return this;
}
delete(ref) {
this.operations.push({
type: 'delete',
path: ref.path,
});
return this;
}
async commit() {
await apiCall('/batch', {
method: 'POST',
body: JSON.stringify({ operations: this.operations }),
});
}
}
exports.WriteBatch = WriteBatch;
class CosmicQuery {
constructor(collectionPath, params, state) {
this.collectionPath = collectionPath;
this.params = params || {
where: [],
orderBy: [],
};
this.state = state || {
whereFields: new Set(),
orderByFields: new Set(),
hasArrayContains: false,
hasInequality: false,
};
}
validateWhere(fieldPath, opStr) {
const isArrayOp = opStr === 'array-contains' || opStr === 'array-contains-any';
const isInequalityOp = ['<', '<=', '>', '>=', '!=', 'not-in'].includes(opStr);
if (!this.state.whereFields.has(fieldPath) && this.state.whereFields.size > 0) {
throw new Error(`${MULTIPLE_WHERE_ERROR}\nAttempted to add where clause on field '${fieldPath}' but already have where clauses on: ${Array.from(this.state.whereFields).join(', ')}`);
}
if (isArrayOp && (this.state.whereFields.size > 0 || this.state.hasInequality)) {
throw new Error(`${COMPOSITE_INDEX_ERROR}\nArray operations (${opStr}) cannot be combined with other where clauses.`);
}
if (isInequalityOp && this.state.hasInequality && this.state.inequalityField !== fieldPath) {
throw new Error(`${COMPOSITE_INDEX_ERROR}\nInequality operations on different fields require composite indexes. Already have inequality on '${this.state.inequalityField}', cannot add inequality on '${fieldPath}'.`);
}
if (this.state.orderByFields.size > 0 && !this.state.orderByFields.has(fieldPath)) {
throw new Error(`${WHERE_WITH_ORDER_BY_ERROR}\nWhere clause on '${fieldPath}' combined with orderBy on '${Array.from(this.state.orderByFields).join(', ')}' requires composite indexes.`);
}
}
validateOrderBy(fieldPath) {
if (this.state.orderByFields.size > 0 && !this.state.orderByFields.has(fieldPath)) {
throw new Error(`${MULTIPLE_ORDER_BY_ERROR}\nAttempted to add orderBy on field '${fieldPath}' but already have orderBy on: ${Array.from(this.state.orderByFields).join(', ')}`);
}
if (this.state.whereFields.size > 0 && !this.state.whereFields.has(fieldPath)) {
throw new Error(`${WHERE_WITH_ORDER_BY_ERROR}\nOrderBy on '${fieldPath}' combined with where clauses on '${Array.from(this.state.whereFields).join(', ')}' requires composite indexes.`);
}
}
where(fieldPath, opStr, value) {
this.validateWhere(fieldPath, opStr);
const newParams = {
...this.params,
where: [...this.params.where, { field: fieldPath, op: opStr, value }],
};
const newState = {
...this.state,
whereFields: new Set([...this.state.whereFields, fieldPath]),
hasArrayContains: this.state.hasArrayContains || ['array-contains', 'array-contains-any'].includes(opStr),
hasInequality: this.state.hasInequality || ['<', '<=', '>', '>=', '!=', 'not-in'].includes(opStr),
inequalityField: ['<', '<=', '>', '>=', '!=', 'not-in'].includes(opStr) ? fieldPath : this.state.inequalityField,
};
return new CosmicQuery(this.collectionPath, newParams, newState);
}
orderBy(fieldPath, directionStr) {
this.validateOrderBy(fieldPath);
const newParams = {
...this.params,
orderBy: [...this.params.orderBy, { field: fieldPath, direction: directionStr }],
};
const newState = {
...this.state,
orderByFields: new Set([...this.state.orderByFields, fieldPath]),
};
return new CosmicQuery(this.collectionPath, newParams, newState);
}
limit(limit) {
return new CosmicQuery(this.collectionPath, { ...this.params, limit }, this.state);
}
offset(offset) {
return new CosmicQuery(this.collectionPath, { ...this.params, offset }, this.state);
}
async get() {
const result = await apiCall('/query', {
method: 'POST',
body: JSON.stringify({
collection: this.collectionPath,
query: this.params,
}),
});
const parent = new CosmicCollectionReference(this.collectionPath);
const docs = result.documents.map((doc) => {
const ref = new DocumentReference(`${this.collectionPath}/${doc.id}`, parent);
return new DocumentSnapshot(doc.id, ref, doc.data);
});
return new QuerySnapshot(docs);
}
}
exports.CosmicQuery = CosmicQuery;
class CosmicCollectionReference {
constructor(path) {
this._path = path;
}
get id() {
const parts = this._path.split('/');
return parts[parts.length - 1];
}
get path() {
return this._path;
}
get parent() {
const parts = this._path.split('/');
if (parts.length <= 2)
return null;
const parentPath = parts.slice(0, -1).join('/');
const grandParentPath = parts.slice(0, -2).join('/');
return new DocumentReference(parentPath, new CosmicCollectionReference(grandParentPath));
}
doc(documentPath) {
const docId = documentPath || this.generateId();
return new DocumentReference(`${this._path}/${docId}`, this);
}
async add(data) {
const docRef = this.doc();
await docRef.set(data);
return docRef;
}
where(fieldPath, opStr, value) {
return new CosmicQuery(this._path).where(fieldPath, opStr, value);
}
orderBy(fieldPath, directionStr) {
return new CosmicQuery(this._path).orderBy(fieldPath, directionStr);
}
limit(limit) {
return new CosmicQuery(this._path).limit(limit);
}
offset(offset) {
return new CosmicQuery(this._path).offset(offset);
}
async get() {
return new CosmicQuery(this._path).get();
}
generateId() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let id = '';
for (let i = 0; i < 20; i++) {
id += chars.charAt(Math.floor(Math.random() * chars.length));
}
return id;
}
}
exports.CosmicCollectionReference = CosmicCollectionReference;
class FieldValue {
static serverTimestamp() {
return { _type: 'serverTimestamp' };
}
static increment(n) {
return { _type: 'increment', value: n };
}
static arrayUnion(...elements) {
return { _type: 'arrayUnion', elements };
}
static arrayRemove(...elements) {
return { _type: 'arrayRemove', elements };
}
static delete() {
return { _type: 'delete' };
}
}
exports.FieldValue = FieldValue;
class Timestamp {
constructor(seconds, nanoseconds) {
this.seconds = seconds;
this.nanoseconds = nanoseconds;
}
static now() {
const now = Date.now();
return new Timestamp(Math.floor(now / 1000), (now % 1000) * 1000000);
}
static fromDate(date) {
const millis = date.getTime();
return new Timestamp(Math.floor(millis / 1000), (millis % 1000) * 1000000);
}
static fromMillis(millis) {
return new Timestamp(Math.floor(millis / 1000), (millis % 1000) * 1000000);
}
toDate() {
return new Date(this.seconds * 1000 + this.nanoseconds / 1000000);
}
toMillis() {
return this.seconds * 1000 + this.nanoseconds / 1000000;
}
}
exports.Timestamp = Timestamp;
class GeoPoint {
constructor(latitude, longitude) {
this.latitude = latitude;
this.longitude = longitude;
}
isEqual(other) {
return this.latitude === other.latitude && this.longitude === other.longitude;
}
}
exports.GeoPoint = GeoPoint;
class CosmicDatabase {
getClientId() {
const clientId = process.env.NEXT_PUBLIC_CLIENT_ID;
if (!clientId) {
throw new Error("❌ COSMIC DATABASE ERROR: NEXT_PUBLIC_CLIENT_ID environment variable is required for multi-tenant operations. Please set this variable to your project's client ID.");
}
return clientId;
}
buildTenantPath(path) {
const clientId = this.getClientId();
const tenantPrefix = `user-project-databases/${clientId}`;
if (path.startsWith(tenantPrefix)) {
return path;
}
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
return `${tenantPrefix}/${cleanPath}`;
}
collection(collectionPath) {
const tenantPath = this.buildTenantPath(collectionPath);
return new CosmicCollectionReference(tenantPath);
}
doc(documentPath) {
const tenantPath = this.buildTenantPath(documentPath);
const parts = tenantPath.split('/');
const collectionPath = parts.slice(0, -1).join('/');
return new DocumentReference(tenantPath, new CosmicCollectionReference(collectionPath));
}
collectionGroup() {
throw new Error("❌ COSMIC DATABASE ERROR: collectionGroup queries are forbidden in Cosmic Database.\n" +
"CollectionGroup queries cannot be tenant-scoped and would compromise data isolation between clients.\n\n" +
"Alternative approaches:\n" +
"1. Query specific collections individually\n" +
"2. Use a denormalized data structure with a dedicated collection\n" +
"3. Store references in a central index collection\n\n" +
"Example: Instead of querying all 'posts' subcollections across users:\n" +
"- Create a top-level 'allPosts' collection with userId field\n" +
"- Query: db.collection('allPosts').where('userId', '==', userId)");
}
getTenantPrefix() {
const clientId = this.getClientId();
return `user-project-databases/${clientId}`;
}
getCurrentClientId() {
return this.getClientId();
}
batch() {
return new WriteBatch();
}
get FieldValue() {
return FieldValue;
}
get Timestamp() {
return Timestamp;
}
get GeoPoint() {
return GeoPoint;
}
}
exports.CosmicDatabase = CosmicDatabase;
exports.db = new CosmicDatabase();