UNPKG

cosmic-database

Version:

A database library for cosmic.new. Designed to be used and deployed on cosmic.new

410 lines (409 loc) 15 kB
"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();