@dalgoridim/headless-cms 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/adapters/firestore/index.ts","../../../src/types.ts"],"sourcesContent":["import admin from \"firebase-admin\";\nimport type {\n Firestore,\n Query as FirestoreQuery,\n WhereFilterOp,\n} from \"firebase-admin/firestore\";\nimport type { DataAdapter, Query, QueryFilterOp } from \"../../types\";\nimport { isFilterGroup } from \"../../types\";\n\nexport interface FirestoreAdapterConfig {\n /** Provide an existing admin Firestore instance… */\n db?: Firestore;\n /** …or service-account credentials to initialize firebase-admin lazily. */\n credentials?: {\n projectId?: string;\n clientEmail?: string;\n privateKey?: string;\n databaseURL?: string;\n };\n /** Field used for default ordering when no query is given. Default `createdAt`. */\n defaultOrderByField?: string;\n}\n\n/**\n * Ops Firestore can honor natively. `contains` (substring search) has no\n * Firestore equivalent and is intentionally absent — using it throws.\n */\nconst OP_MAP: Partial<Record<QueryFilterOp, WhereFilterOp>> = {\n eq: \"==\",\n ne: \"!=\",\n lt: \"<\",\n lte: \"<=\",\n gt: \">\",\n gte: \">=\",\n in: \"in\",\n nin: \"not-in\",\n};\n\n/** Firestore Timestamps → ISO strings, recursively, so the API returns plain JSON. */\nfunction serialize<T>(data: T): T {\n if (Array.isArray(data)) {\n return data.map(serialize) as unknown as T;\n }\n if (data && typeof data === \"object\") {\n const obj = data as Record<string, unknown>;\n if (\"_seconds\" in obj && \"_nanoseconds\" in obj) {\n return new Date(\n (obj._seconds as number) * 1000 + (obj._nanoseconds as number) / 1e6,\n ).toISOString() as unknown as T;\n }\n if (typeof (obj as { toDate?: unknown }).toDate === \"function\") {\n return (obj as { toDate: () => Date }).toDate().toISOString() as unknown as T;\n }\n const out: Record<string, unknown> = {};\n for (const key in obj) out[key] = serialize(obj[key]);\n return out as T;\n }\n return data;\n}\n\nexport class FirestoreDataAdapter implements DataAdapter {\n private readonly db: Firestore;\n private readonly defaultOrderByField: string;\n\n constructor(config: FirestoreAdapterConfig = {}) {\n this.defaultOrderByField = config.defaultOrderByField ?? \"createdAt\";\n\n if (config.db) {\n this.db = config.db;\n return;\n }\n\n if (!admin.apps.length) {\n const c = config.credentials ?? {};\n admin.initializeApp({\n credential: admin.credential.cert({\n projectId: c.projectId,\n clientEmail: c.clientEmail,\n privateKey: c.privateKey,\n }),\n databaseURL: c.databaseURL,\n });\n }\n this.db = admin.firestore();\n }\n\n private buildQuery(collection: string, q?: Query): FirestoreQuery {\n let ref: FirestoreQuery = this.db.collection(collection);\n\n if (!q) {\n return ref.orderBy(this.defaultOrderByField, \"desc\");\n }\n\n for (const c of q.filters ?? []) {\n if (isFilterGroup(c)) {\n throw new Error(\n \"FirestoreDataAdapter does not support OR filter groups. Use a backend \" +\n \"with richer query support (e.g. Postgres) or split into separate reads.\",\n );\n }\n const op = OP_MAP[c.op];\n if (!op) {\n throw new Error(\n `FirestoreDataAdapter does not support the '${c.op}' operator` +\n (c.op === \"contains\" ? \" (no native substring search).\" : \".\"),\n );\n }\n ref = ref.where(c.field, op, c.value);\n }\n for (const o of q.orderBy ?? []) {\n ref = ref.orderBy(o.field, o.direction);\n }\n if (q.offset != null) ref = ref.offset(q.offset);\n if (q.limit != null) ref = ref.limit(q.limit);\n return ref;\n }\n\n async fetchCollection<T = Record<string, unknown>>(\n collection: string,\n q?: Query,\n ): Promise<(T & { id: string })[]> {\n const snap = await this.buildQuery(collection, q).get();\n return snap.docs.map((doc) =>\n serialize({ id: doc.id, ...(doc.data() as T) }),\n );\n }\n\n async fetchById<T = Record<string, unknown>>(\n collection: string,\n id: string,\n ): Promise<(T & { id: string }) | null> {\n const doc = await this.db.collection(collection).doc(id).get();\n if (!doc.exists) return null;\n return serialize({ id: doc.id, ...(doc.data() as T) });\n }\n\n async create<T = Record<string, unknown>>(\n collection: string,\n data: T,\n ): Promise<T & { id: string }> {\n const ref = this.db.collection(collection).doc();\n await ref.set({ ...data, createdAt: new Date(), updatedAt: new Date() });\n return { id: ref.id, ...(data as T) };\n }\n\n async createWithId<T = Record<string, unknown>>(\n collection: string,\n id: string,\n data: T,\n ): Promise<T & { id: string }> {\n await this.db\n .collection(collection)\n .doc(id)\n .set({ ...data, createdAt: new Date(), updatedAt: new Date() });\n return { id, ...(data as T) };\n }\n\n async update<T = Record<string, unknown>>(\n collection: string,\n id: string,\n data: Partial<T>,\n ): Promise<void> {\n await this.db\n .collection(collection)\n .doc(id)\n .update({ ...data, updatedAt: new Date() });\n }\n\n async upsert<T = Record<string, unknown>>(\n collection: string,\n id: string,\n data: Partial<T>,\n ): Promise<void> {\n await this.db\n .collection(collection)\n .doc(id)\n .set({ ...data, updatedAt: new Date() }, { merge: true });\n }\n\n async delete(collection: string, id: string): Promise<void> {\n await this.db.collection(collection).doc(id).delete();\n }\n}\n","/**\n * Shared, runtime-free types. Safe to import in any environment (client or\n * server) — this module pulls in zero runtime dependencies.\n */\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\n\n/**\n * Engine addressing fields. The engine needs to know an entity's `id` and which\n * `collection` it lives in to route saves; everything else is the consumer's own\n * shape. Kept separate from the user's content type `T` so we never force these\n * onto domain models — use {@link Editable}<YourType> when you want both.\n */\nexport interface EntityAddress {\n id: string;\n collection: string;\n}\n\n/**\n * The user's content type `T` decorated with the engine's addressing fields.\n * `T` is completely unconstrained — bring any shape; the engine only adds `id`\n * and `collection`.\n */\nexport type Editable<T = Record<string, any>> = T & EntityAddress;\n\n/**\n * A single editable record. Schemaless by design. Equivalent to\n * `Editable<Record<string, any>>`; kept as a named alias for the common case and\n * for backwards compatibility.\n */\nexport type Section = Editable;\n\nexport type SectionMap = Record<string, Section>;\n\n/**\n * The shape the engine holds in memory:\n * `{ [collection]: { [sectionKey]: Section } }`.\n */\nexport type NestedSections = {\n [collection: string]: {\n [sectionKey: string]: Section;\n };\n};\n\n/**\n * An image edit queued in the provider. The actual upload is deferred until\n * save. `file` is null when the user pasted an external URL (`isExternal`).\n */\nexport interface PendingImage {\n file: File | null;\n localUrl: string;\n sectionKey: string;\n fieldKey: string;\n collection: string;\n docId: string;\n isExternal?: boolean;\n}\n\n/**\n * Neutral query language. Keeps any backend's native query type (Firestore's\n * `Query`, SQL, etc.) from leaking through the engine.\n *\n * Op support is backend-dependent — see each adapter. `contains` is a\n * case-insensitive substring match; `in`/`nin` take an array value.\n */\nexport type QueryFilterOp =\n | \"eq\"\n | \"ne\"\n | \"lt\"\n | \"lte\"\n | \"gt\"\n | \"gte\"\n | \"in\"\n | \"nin\"\n | \"contains\";\n\n/** A single field condition. */\nexport type QueryFilter = { field: string; op: QueryFilterOp; value: unknown };\n\n/**\n * A disjunction: the inner filters are combined with OR. Sits alongside plain\n * filters in `Query.filters`, which are combined with AND at the top level.\n */\nexport type QueryFilterGroup = { or: QueryFilter[] };\n\n/** Either a bare condition (AND-ed) or an OR group. */\nexport type QueryCondition = QueryFilter | QueryFilterGroup;\n\nexport type Query = {\n /** Top-level conditions combined with AND. Use `{ or: [...] }` for disjunction. */\n filters?: QueryCondition[];\n orderBy?: { field: string; direction: \"asc\" | \"desc\" }[];\n limit?: number;\n /** Skip this many rows (offset pagination). */\n offset?: number;\n /**\n * Fields holding references to other documents to inline-resolve after the\n * primary fetch. Resolved by {@link resolveRelations}, never by the adapter.\n */\n populate?: string[];\n};\n\n/** Narrow a {@link QueryCondition} to an OR group. */\nexport function isFilterGroup(c: QueryCondition): c is QueryFilterGroup {\n return typeof c === \"object\" && c !== null && \"or\" in c;\n}\n\n/**\n * A reference to another document. Either self-describing (`{ collection, id }`)\n * or a bare id string resolved via a {@link RelationConfig}.\n */\nexport type Ref = { collection: string; id: string };\n\n/**\n * Maps a reference field name → the collection it points to. Needed only when\n * refs are stored as bare id strings (self-describing `Ref` objects don't need\n * it). Used by {@link resolveRelations}.\n */\nexport type RelationConfig = Record<string, { collection: string }>;\n\n/**\n * Persistence contract. Every backend (Firestore, Postgres, …) implements this;\n * the engine and the route factory only ever speak to this interface.\n */\nexport interface DataAdapter {\n fetchCollection<T = Record<string, any>>(\n collection: string,\n q?: Query,\n ): Promise<(T & { id: string })[]>;\n fetchById<T = Record<string, any>>(\n collection: string,\n id: string,\n ): Promise<(T & { id: string }) | null>;\n create<T = Record<string, any>>(\n collection: string,\n data: T,\n ): Promise<T & { id: string }>;\n createWithId<T = Record<string, any>>(\n collection: string,\n id: string,\n data: T,\n ): Promise<T & { id: string }>;\n update<T = Record<string, any>>(\n collection: string,\n id: string,\n data: Partial<T>,\n ): Promise<void>;\n upsert<T = Record<string, any>>(\n collection: string,\n id: string,\n data: Partial<T>,\n ): Promise<void>;\n delete(collection: string, id: string): Promise<void>;\n}\n\n/**\n * The identity resolved from an incoming request by an {@link AuthAdapter}.\n * Minimal by design: only `isAdmin` is meaningful to the default gate. Carry any\n * additional claims (roles, scopes, tenant, …) as extra keys and authorize on\n * them with a custom `authorize` predicate. `userId`/`email` are conventional\n * but optional.\n */\nexport interface AuthIdentity {\n isAdmin: boolean;\n userId?: string;\n email?: string;\n [claim: string]: unknown;\n}\n\n/**\n * Decides whether a resolved identity may perform admin actions. Defaults to\n * `identity.isAdmin === true`; override to gate on roles/scopes/etc.\n */\nexport type AuthorizeFn = (\n identity: AuthIdentity,\n req: Request,\n) => boolean | Promise<boolean>;\n\n/**\n * Server-side auth contract. Gates every admin API route. Return `null` to\n * reject outright; otherwise the gate's `authorize` predicate decides.\n */\nexport interface AuthAdapter {\n verifyRequest(req: Request): Promise<AuthIdentity | null>;\n}\n\n/**\n * Client half of storage: performs the browser-side upload and returns the\n * final URL. Has zero server dependencies, so it is safe to import in client\n * components. Built from e.g. `@dalgoridim/headless-cms/storage/cloudinary`.\n */\nexport interface ClientStorageAdapter {\n upload(file: File): Promise<{ url: string }>;\n}\n\n/**\n * Server half of storage: issues a presign / signature (or, for local storage,\n * writes the file). Mounted by the route factory at `${apiBasePath}/sign`.\n * Pulls in server-only SDKs, so it lives in a `/server` subpath. Built from e.g.\n * `@dalgoridim/headless-cms/storage/cloudinary/server`.\n */\nexport interface ServerStorageAdapter {\n sign(req: Request): Promise<unknown>;\n}\n\n/** Full two-sided contract (rarely needed; client and server halves are split). */\nexport interface StorageAdapter extends ClientStorageAdapter {\n sign?(req: Request): Promise<unknown>;\n}\n\n/**\n * Minimal client-side auth state the edit primitives depend on. Any auth\n * implementation (Firebase, NextAuth, custom) provides this via a context;\n * `@dalgoridim/headless-cms/auth/firebase/client` ships the default.\n */\nexport interface CmsAuthState {\n isAdmin: boolean;\n isEditing: boolean;\n toggleEdit: () => void;\n}\n"],"mappings":"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","names":["admin"]}
1
+ {"version":3,"sources":["../../../src/adapters/firestore/index.ts","../../../src/types.ts"],"sourcesContent":["import admin from \"firebase-admin\";\nimport type {\n Firestore,\n Query as FirestoreQuery,\n WhereFilterOp,\n} from \"firebase-admin/firestore\";\nimport type { DataAdapter, Query, QueryFilterOp } from \"../../types\";\nimport { isFilterGroup } from \"../../types\";\n\nexport interface FirestoreAdapterConfig {\n /** Provide an existing admin Firestore instance… */\n db?: Firestore;\n /** …or service-account credentials to initialize firebase-admin lazily. */\n credentials?: {\n projectId?: string;\n clientEmail?: string;\n privateKey?: string;\n databaseURL?: string;\n };\n /** Field used for default ordering when no query is given. Default `createdAt`. */\n defaultOrderByField?: string;\n}\n\n/**\n * Ops Firestore can honor natively. `contains` (substring search) has no\n * Firestore equivalent and is intentionally absent — using it throws.\n */\nconst OP_MAP: Partial<Record<QueryFilterOp, WhereFilterOp>> = {\n eq: \"==\",\n ne: \"!=\",\n lt: \"<\",\n lte: \"<=\",\n gt: \">\",\n gte: \">=\",\n in: \"in\",\n nin: \"not-in\",\n};\n\n/** Firestore Timestamps → ISO strings, recursively, so the API returns plain JSON. */\nfunction serialize<T>(data: T): T {\n if (Array.isArray(data)) {\n return data.map(serialize) as unknown as T;\n }\n if (data && typeof data === \"object\") {\n const obj = data as Record<string, unknown>;\n if (\"_seconds\" in obj && \"_nanoseconds\" in obj) {\n return new Date(\n (obj._seconds as number) * 1000 + (obj._nanoseconds as number) / 1e6,\n ).toISOString() as unknown as T;\n }\n if (typeof (obj as { toDate?: unknown }).toDate === \"function\") {\n return (obj as { toDate: () => Date }).toDate().toISOString() as unknown as T;\n }\n const out: Record<string, unknown> = {};\n for (const key in obj) out[key] = serialize(obj[key]);\n return out as T;\n }\n return data;\n}\n\nexport class FirestoreDataAdapter implements DataAdapter {\n private readonly db: Firestore;\n private readonly defaultOrderByField: string;\n\n constructor(config: FirestoreAdapterConfig = {}) {\n this.defaultOrderByField = config.defaultOrderByField ?? \"createdAt\";\n\n if (config.db) {\n this.db = config.db;\n return;\n }\n\n if (!admin.apps.length) {\n const c = config.credentials ?? {};\n admin.initializeApp({\n credential: admin.credential.cert({\n projectId: c.projectId,\n clientEmail: c.clientEmail,\n privateKey: c.privateKey,\n }),\n databaseURL: c.databaseURL,\n });\n }\n this.db = admin.firestore();\n }\n\n private buildQuery(collection: string, q?: Query): FirestoreQuery {\n let ref: FirestoreQuery = this.db.collection(collection);\n\n if (!q) {\n return ref.orderBy(this.defaultOrderByField, \"desc\");\n }\n\n for (const c of q.filters ?? []) {\n if (isFilterGroup(c)) {\n throw new Error(\n \"FirestoreDataAdapter does not support OR filter groups. Use a backend \" +\n \"with richer query support (e.g. Postgres) or split into separate reads.\",\n );\n }\n const op = OP_MAP[c.op];\n if (!op) {\n throw new Error(\n `FirestoreDataAdapter does not support the '${c.op}' operator` +\n (c.op === \"contains\" ? \" (no native substring search).\" : \".\"),\n );\n }\n ref = ref.where(c.field, op, c.value);\n }\n for (const o of q.orderBy ?? []) {\n ref = ref.orderBy(o.field, o.direction);\n }\n if (q.offset != null) ref = ref.offset(q.offset);\n if (q.limit != null) ref = ref.limit(q.limit);\n return ref;\n }\n\n async fetchCollection<T = Record<string, unknown>>(\n collection: string,\n q?: Query,\n ): Promise<(T & { id: string })[]> {\n const snap = await this.buildQuery(collection, q).get();\n return snap.docs.map((doc) =>\n serialize({ id: doc.id, ...(doc.data() as T) }),\n );\n }\n\n async fetchById<T = Record<string, unknown>>(\n collection: string,\n id: string,\n ): Promise<(T & { id: string }) | null> {\n const doc = await this.db.collection(collection).doc(id).get();\n if (!doc.exists) return null;\n return serialize({ id: doc.id, ...(doc.data() as T) });\n }\n\n async create<T = Record<string, unknown>>(\n collection: string,\n data: T,\n ): Promise<T & { id: string }> {\n const ref = this.db.collection(collection).doc();\n await ref.set({ ...data, createdAt: new Date(), updatedAt: new Date() });\n return { id: ref.id, ...(data as T) };\n }\n\n async createWithId<T = Record<string, unknown>>(\n collection: string,\n id: string,\n data: T,\n ): Promise<T & { id: string }> {\n await this.db\n .collection(collection)\n .doc(id)\n .set({ ...data, createdAt: new Date(), updatedAt: new Date() });\n return { id, ...(data as T) };\n }\n\n async update<T = Record<string, unknown>>(\n collection: string,\n id: string,\n data: Partial<T>,\n ): Promise<void> {\n await this.db\n .collection(collection)\n .doc(id)\n .update({ ...data, updatedAt: new Date() });\n }\n\n async upsert<T = Record<string, unknown>>(\n collection: string,\n id: string,\n data: Partial<T>,\n ): Promise<void> {\n await this.db\n .collection(collection)\n .doc(id)\n .set({ ...data, updatedAt: new Date() }, { merge: true });\n }\n\n async delete(collection: string, id: string): Promise<void> {\n await this.db.collection(collection).doc(id).delete();\n }\n}\n","/**\n * Shared, runtime-free types. Safe to import in any environment (client or\n * server) — this module pulls in zero runtime dependencies.\n */\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\n\n/**\n * Engine addressing fields. The engine needs to know an entity's `id` and which\n * `collection` it lives in to route saves; everything else is the consumer's own\n * shape. Kept separate from the user's content type `T` so we never force these\n * onto domain models — use {@link Editable}<YourType> when you want both.\n */\nexport interface EntityAddress {\n id: string;\n collection: string;\n}\n\n/**\n * The user's content type `T` decorated with the engine's addressing fields.\n * `T` is completely unconstrained — bring any shape; the engine only adds `id`\n * and `collection`.\n */\nexport type Editable<T = Record<string, any>> = T & EntityAddress;\n\n/**\n * A single editable record. Schemaless by design. Equivalent to\n * `Editable<Record<string, any>>`; kept as a named alias for the common case and\n * for backwards compatibility.\n */\nexport type Section = Editable;\n\nexport type SectionMap = Record<string, Section>;\n\n/**\n * The shape the engine holds in memory:\n * `{ [collection]: { [sectionKey]: Section } }`.\n */\nexport type NestedSections = {\n [collection: string]: {\n [sectionKey: string]: Section;\n };\n};\n\n/**\n * A single item in an editable collection list (e.g. a project or a tool). Just\n * a record with a stable `id`. Used by the client provider's collection ops\n * (create / delete / reorder) which manage *which* items exist and their order,\n * complementing {@link Section}s which manage a single item's editable fields.\n */\nexport type CollectionItem = Record<string, unknown> & { id: string };\n\n/**\n * An image edit queued in the provider. The actual upload is deferred until\n * save. `file` is null when the user pasted an external URL (`isExternal`).\n */\nexport interface PendingImage {\n file: File | null;\n localUrl: string;\n sectionKey: string;\n fieldKey: string;\n collection: string;\n docId: string;\n isExternal?: boolean;\n}\n\n/**\n * Neutral query language. Keeps any backend's native query type (Firestore's\n * `Query`, SQL, etc.) from leaking through the engine.\n *\n * Op support is backend-dependent — see each adapter. `contains` is a\n * case-insensitive substring match; `in`/`nin` take an array value.\n */\nexport type QueryFilterOp =\n | \"eq\"\n | \"ne\"\n | \"lt\"\n | \"lte\"\n | \"gt\"\n | \"gte\"\n | \"in\"\n | \"nin\"\n | \"contains\";\n\n/** A single field condition. */\nexport type QueryFilter = { field: string; op: QueryFilterOp; value: unknown };\n\n/**\n * A disjunction: the inner filters are combined with OR. Sits alongside plain\n * filters in `Query.filters`, which are combined with AND at the top level.\n */\nexport type QueryFilterGroup = { or: QueryFilter[] };\n\n/** Either a bare condition (AND-ed) or an OR group. */\nexport type QueryCondition = QueryFilter | QueryFilterGroup;\n\nexport type Query = {\n /** Top-level conditions combined with AND. Use `{ or: [...] }` for disjunction. */\n filters?: QueryCondition[];\n orderBy?: { field: string; direction: \"asc\" | \"desc\" }[];\n limit?: number;\n /** Skip this many rows (offset pagination). */\n offset?: number;\n /**\n * Fields holding references to other documents to inline-resolve after the\n * primary fetch. Resolved by {@link resolveRelations}, never by the adapter.\n */\n populate?: string[];\n};\n\n/** Narrow a {@link QueryCondition} to an OR group. */\nexport function isFilterGroup(c: QueryCondition): c is QueryFilterGroup {\n return typeof c === \"object\" && c !== null && \"or\" in c;\n}\n\n/**\n * A reference to another document. Either self-describing (`{ collection, id }`)\n * or a bare id string resolved via a {@link RelationConfig}.\n */\nexport type Ref = { collection: string; id: string };\n\n/**\n * Maps a reference field name → the collection it points to. Needed only when\n * refs are stored as bare id strings (self-describing `Ref` objects don't need\n * it). Used by {@link resolveRelations}.\n */\nexport type RelationConfig = Record<string, { collection: string }>;\n\n/**\n * Persistence contract. Every backend (Firestore, Postgres, …) implements this;\n * the engine and the route factory only ever speak to this interface.\n */\nexport interface DataAdapter {\n fetchCollection<T = Record<string, any>>(\n collection: string,\n q?: Query,\n ): Promise<(T & { id: string })[]>;\n fetchById<T = Record<string, any>>(\n collection: string,\n id: string,\n ): Promise<(T & { id: string }) | null>;\n create<T = Record<string, any>>(\n collection: string,\n data: T,\n ): Promise<T & { id: string }>;\n createWithId<T = Record<string, any>>(\n collection: string,\n id: string,\n data: T,\n ): Promise<T & { id: string }>;\n update<T = Record<string, any>>(\n collection: string,\n id: string,\n data: Partial<T>,\n ): Promise<void>;\n upsert<T = Record<string, any>>(\n collection: string,\n id: string,\n data: Partial<T>,\n ): Promise<void>;\n delete(collection: string, id: string): Promise<void>;\n}\n\n/**\n * The identity resolved from an incoming request by an {@link AuthAdapter}.\n * Minimal by design: only `isAdmin` is meaningful to the default gate. Carry any\n * additional claims (roles, scopes, tenant, …) as extra keys and authorize on\n * them with a custom `authorize` predicate. `userId`/`email` are conventional\n * but optional.\n */\nexport interface AuthIdentity {\n isAdmin: boolean;\n userId?: string;\n email?: string;\n [claim: string]: unknown;\n}\n\n/**\n * Decides whether a resolved identity may perform admin actions. Defaults to\n * `identity.isAdmin === true`; override to gate on roles/scopes/etc.\n */\nexport type AuthorizeFn = (\n identity: AuthIdentity,\n req: Request,\n) => boolean | Promise<boolean>;\n\n/**\n * Server-side auth contract. Gates every admin API route. Return `null` to\n * reject outright; otherwise the gate's `authorize` predicate decides.\n */\nexport interface AuthAdapter {\n verifyRequest(req: Request): Promise<AuthIdentity | null>;\n}\n\n/**\n * Client half of storage: performs the browser-side upload and returns the\n * final URL. Has zero server dependencies, so it is safe to import in client\n * components. Built from e.g. `@dalgoridim/headless-cms/storage/cloudinary`.\n */\nexport interface ClientStorageAdapter {\n upload(file: File): Promise<{ url: string }>;\n}\n\n/**\n * Server half of storage: issues a presign / signature (or, for local storage,\n * writes the file). Mounted by the route factory at `${apiBasePath}/sign`.\n * Pulls in server-only SDKs, so it lives in a `/server` subpath. Built from e.g.\n * `@dalgoridim/headless-cms/storage/cloudinary/server`.\n */\nexport interface ServerStorageAdapter {\n sign(req: Request): Promise<unknown>;\n}\n\n/** Full two-sided contract (rarely needed; client and server halves are split). */\nexport interface StorageAdapter extends ClientStorageAdapter {\n sign?(req: Request): Promise<unknown>;\n}\n\n/**\n * Minimal client-side auth state the edit primitives depend on. Any auth\n * implementation (Firebase, NextAuth, custom) provides this via a context;\n * `@dalgoridim/headless-cms/auth/firebase/client` ships the default.\n */\nexport interface CmsAuthState {\n isAdmin: boolean;\n isEditing: boolean;\n toggleEdit: () => void;\n}\n"],"mappings":"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","names":["admin"]}
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/adapters/firestore/index.ts","../../../src/types.ts"],"sourcesContent":["import admin from \"firebase-admin\";\nimport type {\n Firestore,\n Query as FirestoreQuery,\n WhereFilterOp,\n} from \"firebase-admin/firestore\";\nimport type { DataAdapter, Query, QueryFilterOp } from \"../../types\";\nimport { isFilterGroup } from \"../../types\";\n\nexport interface FirestoreAdapterConfig {\n /** Provide an existing admin Firestore instance… */\n db?: Firestore;\n /** …or service-account credentials to initialize firebase-admin lazily. */\n credentials?: {\n projectId?: string;\n clientEmail?: string;\n privateKey?: string;\n databaseURL?: string;\n };\n /** Field used for default ordering when no query is given. Default `createdAt`. */\n defaultOrderByField?: string;\n}\n\n/**\n * Ops Firestore can honor natively. `contains` (substring search) has no\n * Firestore equivalent and is intentionally absent — using it throws.\n */\nconst OP_MAP: Partial<Record<QueryFilterOp, WhereFilterOp>> = {\n eq: \"==\",\n ne: \"!=\",\n lt: \"<\",\n lte: \"<=\",\n gt: \">\",\n gte: \">=\",\n in: \"in\",\n nin: \"not-in\",\n};\n\n/** Firestore Timestamps → ISO strings, recursively, so the API returns plain JSON. */\nfunction serialize<T>(data: T): T {\n if (Array.isArray(data)) {\n return data.map(serialize) as unknown as T;\n }\n if (data && typeof data === \"object\") {\n const obj = data as Record<string, unknown>;\n if (\"_seconds\" in obj && \"_nanoseconds\" in obj) {\n return new Date(\n (obj._seconds as number) * 1000 + (obj._nanoseconds as number) / 1e6,\n ).toISOString() as unknown as T;\n }\n if (typeof (obj as { toDate?: unknown }).toDate === \"function\") {\n return (obj as { toDate: () => Date }).toDate().toISOString() as unknown as T;\n }\n const out: Record<string, unknown> = {};\n for (const key in obj) out[key] = serialize(obj[key]);\n return out as T;\n }\n return data;\n}\n\nexport class FirestoreDataAdapter implements DataAdapter {\n private readonly db: Firestore;\n private readonly defaultOrderByField: string;\n\n constructor(config: FirestoreAdapterConfig = {}) {\n this.defaultOrderByField = config.defaultOrderByField ?? \"createdAt\";\n\n if (config.db) {\n this.db = config.db;\n return;\n }\n\n if (!admin.apps.length) {\n const c = config.credentials ?? {};\n admin.initializeApp({\n credential: admin.credential.cert({\n projectId: c.projectId,\n clientEmail: c.clientEmail,\n privateKey: c.privateKey,\n }),\n databaseURL: c.databaseURL,\n });\n }\n this.db = admin.firestore();\n }\n\n private buildQuery(collection: string, q?: Query): FirestoreQuery {\n let ref: FirestoreQuery = this.db.collection(collection);\n\n if (!q) {\n return ref.orderBy(this.defaultOrderByField, \"desc\");\n }\n\n for (const c of q.filters ?? []) {\n if (isFilterGroup(c)) {\n throw new Error(\n \"FirestoreDataAdapter does not support OR filter groups. Use a backend \" +\n \"with richer query support (e.g. Postgres) or split into separate reads.\",\n );\n }\n const op = OP_MAP[c.op];\n if (!op) {\n throw new Error(\n `FirestoreDataAdapter does not support the '${c.op}' operator` +\n (c.op === \"contains\" ? \" (no native substring search).\" : \".\"),\n );\n }\n ref = ref.where(c.field, op, c.value);\n }\n for (const o of q.orderBy ?? []) {\n ref = ref.orderBy(o.field, o.direction);\n }\n if (q.offset != null) ref = ref.offset(q.offset);\n if (q.limit != null) ref = ref.limit(q.limit);\n return ref;\n }\n\n async fetchCollection<T = Record<string, unknown>>(\n collection: string,\n q?: Query,\n ): Promise<(T & { id: string })[]> {\n const snap = await this.buildQuery(collection, q).get();\n return snap.docs.map((doc) =>\n serialize({ id: doc.id, ...(doc.data() as T) }),\n );\n }\n\n async fetchById<T = Record<string, unknown>>(\n collection: string,\n id: string,\n ): Promise<(T & { id: string }) | null> {\n const doc = await this.db.collection(collection).doc(id).get();\n if (!doc.exists) return null;\n return serialize({ id: doc.id, ...(doc.data() as T) });\n }\n\n async create<T = Record<string, unknown>>(\n collection: string,\n data: T,\n ): Promise<T & { id: string }> {\n const ref = this.db.collection(collection).doc();\n await ref.set({ ...data, createdAt: new Date(), updatedAt: new Date() });\n return { id: ref.id, ...(data as T) };\n }\n\n async createWithId<T = Record<string, unknown>>(\n collection: string,\n id: string,\n data: T,\n ): Promise<T & { id: string }> {\n await this.db\n .collection(collection)\n .doc(id)\n .set({ ...data, createdAt: new Date(), updatedAt: new Date() });\n return { id, ...(data as T) };\n }\n\n async update<T = Record<string, unknown>>(\n collection: string,\n id: string,\n data: Partial<T>,\n ): Promise<void> {\n await this.db\n .collection(collection)\n .doc(id)\n .update({ ...data, updatedAt: new Date() });\n }\n\n async upsert<T = Record<string, unknown>>(\n collection: string,\n id: string,\n data: Partial<T>,\n ): Promise<void> {\n await this.db\n .collection(collection)\n .doc(id)\n .set({ ...data, updatedAt: new Date() }, { merge: true });\n }\n\n async delete(collection: string, id: string): Promise<void> {\n await this.db.collection(collection).doc(id).delete();\n }\n}\n","/**\n * Shared, runtime-free types. Safe to import in any environment (client or\n * server) — this module pulls in zero runtime dependencies.\n */\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\n\n/**\n * Engine addressing fields. The engine needs to know an entity's `id` and which\n * `collection` it lives in to route saves; everything else is the consumer's own\n * shape. Kept separate from the user's content type `T` so we never force these\n * onto domain models — use {@link Editable}<YourType> when you want both.\n */\nexport interface EntityAddress {\n id: string;\n collection: string;\n}\n\n/**\n * The user's content type `T` decorated with the engine's addressing fields.\n * `T` is completely unconstrained — bring any shape; the engine only adds `id`\n * and `collection`.\n */\nexport type Editable<T = Record<string, any>> = T & EntityAddress;\n\n/**\n * A single editable record. Schemaless by design. Equivalent to\n * `Editable<Record<string, any>>`; kept as a named alias for the common case and\n * for backwards compatibility.\n */\nexport type Section = Editable;\n\nexport type SectionMap = Record<string, Section>;\n\n/**\n * The shape the engine holds in memory:\n * `{ [collection]: { [sectionKey]: Section } }`.\n */\nexport type NestedSections = {\n [collection: string]: {\n [sectionKey: string]: Section;\n };\n};\n\n/**\n * An image edit queued in the provider. The actual upload is deferred until\n * save. `file` is null when the user pasted an external URL (`isExternal`).\n */\nexport interface PendingImage {\n file: File | null;\n localUrl: string;\n sectionKey: string;\n fieldKey: string;\n collection: string;\n docId: string;\n isExternal?: boolean;\n}\n\n/**\n * Neutral query language. Keeps any backend's native query type (Firestore's\n * `Query`, SQL, etc.) from leaking through the engine.\n *\n * Op support is backend-dependent — see each adapter. `contains` is a\n * case-insensitive substring match; `in`/`nin` take an array value.\n */\nexport type QueryFilterOp =\n | \"eq\"\n | \"ne\"\n | \"lt\"\n | \"lte\"\n | \"gt\"\n | \"gte\"\n | \"in\"\n | \"nin\"\n | \"contains\";\n\n/** A single field condition. */\nexport type QueryFilter = { field: string; op: QueryFilterOp; value: unknown };\n\n/**\n * A disjunction: the inner filters are combined with OR. Sits alongside plain\n * filters in `Query.filters`, which are combined with AND at the top level.\n */\nexport type QueryFilterGroup = { or: QueryFilter[] };\n\n/** Either a bare condition (AND-ed) or an OR group. */\nexport type QueryCondition = QueryFilter | QueryFilterGroup;\n\nexport type Query = {\n /** Top-level conditions combined with AND. Use `{ or: [...] }` for disjunction. */\n filters?: QueryCondition[];\n orderBy?: { field: string; direction: \"asc\" | \"desc\" }[];\n limit?: number;\n /** Skip this many rows (offset pagination). */\n offset?: number;\n /**\n * Fields holding references to other documents to inline-resolve after the\n * primary fetch. Resolved by {@link resolveRelations}, never by the adapter.\n */\n populate?: string[];\n};\n\n/** Narrow a {@link QueryCondition} to an OR group. */\nexport function isFilterGroup(c: QueryCondition): c is QueryFilterGroup {\n return typeof c === \"object\" && c !== null && \"or\" in c;\n}\n\n/**\n * A reference to another document. Either self-describing (`{ collection, id }`)\n * or a bare id string resolved via a {@link RelationConfig}.\n */\nexport type Ref = { collection: string; id: string };\n\n/**\n * Maps a reference field name → the collection it points to. Needed only when\n * refs are stored as bare id strings (self-describing `Ref` objects don't need\n * it). Used by {@link resolveRelations}.\n */\nexport type RelationConfig = Record<string, { collection: string }>;\n\n/**\n * Persistence contract. Every backend (Firestore, Postgres, …) implements this;\n * the engine and the route factory only ever speak to this interface.\n */\nexport interface DataAdapter {\n fetchCollection<T = Record<string, any>>(\n collection: string,\n q?: Query,\n ): Promise<(T & { id: string })[]>;\n fetchById<T = Record<string, any>>(\n collection: string,\n id: string,\n ): Promise<(T & { id: string }) | null>;\n create<T = Record<string, any>>(\n collection: string,\n data: T,\n ): Promise<T & { id: string }>;\n createWithId<T = Record<string, any>>(\n collection: string,\n id: string,\n data: T,\n ): Promise<T & { id: string }>;\n update<T = Record<string, any>>(\n collection: string,\n id: string,\n data: Partial<T>,\n ): Promise<void>;\n upsert<T = Record<string, any>>(\n collection: string,\n id: string,\n data: Partial<T>,\n ): Promise<void>;\n delete(collection: string, id: string): Promise<void>;\n}\n\n/**\n * The identity resolved from an incoming request by an {@link AuthAdapter}.\n * Minimal by design: only `isAdmin` is meaningful to the default gate. Carry any\n * additional claims (roles, scopes, tenant, …) as extra keys and authorize on\n * them with a custom `authorize` predicate. `userId`/`email` are conventional\n * but optional.\n */\nexport interface AuthIdentity {\n isAdmin: boolean;\n userId?: string;\n email?: string;\n [claim: string]: unknown;\n}\n\n/**\n * Decides whether a resolved identity may perform admin actions. Defaults to\n * `identity.isAdmin === true`; override to gate on roles/scopes/etc.\n */\nexport type AuthorizeFn = (\n identity: AuthIdentity,\n req: Request,\n) => boolean | Promise<boolean>;\n\n/**\n * Server-side auth contract. Gates every admin API route. Return `null` to\n * reject outright; otherwise the gate's `authorize` predicate decides.\n */\nexport interface AuthAdapter {\n verifyRequest(req: Request): Promise<AuthIdentity | null>;\n}\n\n/**\n * Client half of storage: performs the browser-side upload and returns the\n * final URL. Has zero server dependencies, so it is safe to import in client\n * components. Built from e.g. `@dalgoridim/headless-cms/storage/cloudinary`.\n */\nexport interface ClientStorageAdapter {\n upload(file: File): Promise<{ url: string }>;\n}\n\n/**\n * Server half of storage: issues a presign / signature (or, for local storage,\n * writes the file). Mounted by the route factory at `${apiBasePath}/sign`.\n * Pulls in server-only SDKs, so it lives in a `/server` subpath. Built from e.g.\n * `@dalgoridim/headless-cms/storage/cloudinary/server`.\n */\nexport interface ServerStorageAdapter {\n sign(req: Request): Promise<unknown>;\n}\n\n/** Full two-sided contract (rarely needed; client and server halves are split). */\nexport interface StorageAdapter extends ClientStorageAdapter {\n sign?(req: Request): Promise<unknown>;\n}\n\n/**\n * Minimal client-side auth state the edit primitives depend on. Any auth\n * implementation (Firebase, NextAuth, custom) provides this via a context;\n * `@dalgoridim/headless-cms/auth/firebase/client` ships the default.\n */\nexport interface CmsAuthState {\n isAdmin: boolean;\n isEditing: boolean;\n toggleEdit: () => void;\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../../src/adapters/firestore/index.ts","../../../src/types.ts"],"sourcesContent":["import admin from \"firebase-admin\";\nimport type {\n Firestore,\n Query as FirestoreQuery,\n WhereFilterOp,\n} from \"firebase-admin/firestore\";\nimport type { DataAdapter, Query, QueryFilterOp } from \"../../types\";\nimport { isFilterGroup } from \"../../types\";\n\nexport interface FirestoreAdapterConfig {\n /** Provide an existing admin Firestore instance… */\n db?: Firestore;\n /** …or service-account credentials to initialize firebase-admin lazily. */\n credentials?: {\n projectId?: string;\n clientEmail?: string;\n privateKey?: string;\n databaseURL?: string;\n };\n /** Field used for default ordering when no query is given. Default `createdAt`. */\n defaultOrderByField?: string;\n}\n\n/**\n * Ops Firestore can honor natively. `contains` (substring search) has no\n * Firestore equivalent and is intentionally absent — using it throws.\n */\nconst OP_MAP: Partial<Record<QueryFilterOp, WhereFilterOp>> = {\n eq: \"==\",\n ne: \"!=\",\n lt: \"<\",\n lte: \"<=\",\n gt: \">\",\n gte: \">=\",\n in: \"in\",\n nin: \"not-in\",\n};\n\n/** Firestore Timestamps → ISO strings, recursively, so the API returns plain JSON. */\nfunction serialize<T>(data: T): T {\n if (Array.isArray(data)) {\n return data.map(serialize) as unknown as T;\n }\n if (data && typeof data === \"object\") {\n const obj = data as Record<string, unknown>;\n if (\"_seconds\" in obj && \"_nanoseconds\" in obj) {\n return new Date(\n (obj._seconds as number) * 1000 + (obj._nanoseconds as number) / 1e6,\n ).toISOString() as unknown as T;\n }\n if (typeof (obj as { toDate?: unknown }).toDate === \"function\") {\n return (obj as { toDate: () => Date }).toDate().toISOString() as unknown as T;\n }\n const out: Record<string, unknown> = {};\n for (const key in obj) out[key] = serialize(obj[key]);\n return out as T;\n }\n return data;\n}\n\nexport class FirestoreDataAdapter implements DataAdapter {\n private readonly db: Firestore;\n private readonly defaultOrderByField: string;\n\n constructor(config: FirestoreAdapterConfig = {}) {\n this.defaultOrderByField = config.defaultOrderByField ?? \"createdAt\";\n\n if (config.db) {\n this.db = config.db;\n return;\n }\n\n if (!admin.apps.length) {\n const c = config.credentials ?? {};\n admin.initializeApp({\n credential: admin.credential.cert({\n projectId: c.projectId,\n clientEmail: c.clientEmail,\n privateKey: c.privateKey,\n }),\n databaseURL: c.databaseURL,\n });\n }\n this.db = admin.firestore();\n }\n\n private buildQuery(collection: string, q?: Query): FirestoreQuery {\n let ref: FirestoreQuery = this.db.collection(collection);\n\n if (!q) {\n return ref.orderBy(this.defaultOrderByField, \"desc\");\n }\n\n for (const c of q.filters ?? []) {\n if (isFilterGroup(c)) {\n throw new Error(\n \"FirestoreDataAdapter does not support OR filter groups. Use a backend \" +\n \"with richer query support (e.g. Postgres) or split into separate reads.\",\n );\n }\n const op = OP_MAP[c.op];\n if (!op) {\n throw new Error(\n `FirestoreDataAdapter does not support the '${c.op}' operator` +\n (c.op === \"contains\" ? \" (no native substring search).\" : \".\"),\n );\n }\n ref = ref.where(c.field, op, c.value);\n }\n for (const o of q.orderBy ?? []) {\n ref = ref.orderBy(o.field, o.direction);\n }\n if (q.offset != null) ref = ref.offset(q.offset);\n if (q.limit != null) ref = ref.limit(q.limit);\n return ref;\n }\n\n async fetchCollection<T = Record<string, unknown>>(\n collection: string,\n q?: Query,\n ): Promise<(T & { id: string })[]> {\n const snap = await this.buildQuery(collection, q).get();\n return snap.docs.map((doc) =>\n serialize({ id: doc.id, ...(doc.data() as T) }),\n );\n }\n\n async fetchById<T = Record<string, unknown>>(\n collection: string,\n id: string,\n ): Promise<(T & { id: string }) | null> {\n const doc = await this.db.collection(collection).doc(id).get();\n if (!doc.exists) return null;\n return serialize({ id: doc.id, ...(doc.data() as T) });\n }\n\n async create<T = Record<string, unknown>>(\n collection: string,\n data: T,\n ): Promise<T & { id: string }> {\n const ref = this.db.collection(collection).doc();\n await ref.set({ ...data, createdAt: new Date(), updatedAt: new Date() });\n return { id: ref.id, ...(data as T) };\n }\n\n async createWithId<T = Record<string, unknown>>(\n collection: string,\n id: string,\n data: T,\n ): Promise<T & { id: string }> {\n await this.db\n .collection(collection)\n .doc(id)\n .set({ ...data, createdAt: new Date(), updatedAt: new Date() });\n return { id, ...(data as T) };\n }\n\n async update<T = Record<string, unknown>>(\n collection: string,\n id: string,\n data: Partial<T>,\n ): Promise<void> {\n await this.db\n .collection(collection)\n .doc(id)\n .update({ ...data, updatedAt: new Date() });\n }\n\n async upsert<T = Record<string, unknown>>(\n collection: string,\n id: string,\n data: Partial<T>,\n ): Promise<void> {\n await this.db\n .collection(collection)\n .doc(id)\n .set({ ...data, updatedAt: new Date() }, { merge: true });\n }\n\n async delete(collection: string, id: string): Promise<void> {\n await this.db.collection(collection).doc(id).delete();\n }\n}\n","/**\n * Shared, runtime-free types. Safe to import in any environment (client or\n * server) — this module pulls in zero runtime dependencies.\n */\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\n\n/**\n * Engine addressing fields. The engine needs to know an entity's `id` and which\n * `collection` it lives in to route saves; everything else is the consumer's own\n * shape. Kept separate from the user's content type `T` so we never force these\n * onto domain models — use {@link Editable}<YourType> when you want both.\n */\nexport interface EntityAddress {\n id: string;\n collection: string;\n}\n\n/**\n * The user's content type `T` decorated with the engine's addressing fields.\n * `T` is completely unconstrained — bring any shape; the engine only adds `id`\n * and `collection`.\n */\nexport type Editable<T = Record<string, any>> = T & EntityAddress;\n\n/**\n * A single editable record. Schemaless by design. Equivalent to\n * `Editable<Record<string, any>>`; kept as a named alias for the common case and\n * for backwards compatibility.\n */\nexport type Section = Editable;\n\nexport type SectionMap = Record<string, Section>;\n\n/**\n * The shape the engine holds in memory:\n * `{ [collection]: { [sectionKey]: Section } }`.\n */\nexport type NestedSections = {\n [collection: string]: {\n [sectionKey: string]: Section;\n };\n};\n\n/**\n * A single item in an editable collection list (e.g. a project or a tool). Just\n * a record with a stable `id`. Used by the client provider's collection ops\n * (create / delete / reorder) which manage *which* items exist and their order,\n * complementing {@link Section}s which manage a single item's editable fields.\n */\nexport type CollectionItem = Record<string, unknown> & { id: string };\n\n/**\n * An image edit queued in the provider. The actual upload is deferred until\n * save. `file` is null when the user pasted an external URL (`isExternal`).\n */\nexport interface PendingImage {\n file: File | null;\n localUrl: string;\n sectionKey: string;\n fieldKey: string;\n collection: string;\n docId: string;\n isExternal?: boolean;\n}\n\n/**\n * Neutral query language. Keeps any backend's native query type (Firestore's\n * `Query`, SQL, etc.) from leaking through the engine.\n *\n * Op support is backend-dependent — see each adapter. `contains` is a\n * case-insensitive substring match; `in`/`nin` take an array value.\n */\nexport type QueryFilterOp =\n | \"eq\"\n | \"ne\"\n | \"lt\"\n | \"lte\"\n | \"gt\"\n | \"gte\"\n | \"in\"\n | \"nin\"\n | \"contains\";\n\n/** A single field condition. */\nexport type QueryFilter = { field: string; op: QueryFilterOp; value: unknown };\n\n/**\n * A disjunction: the inner filters are combined with OR. Sits alongside plain\n * filters in `Query.filters`, which are combined with AND at the top level.\n */\nexport type QueryFilterGroup = { or: QueryFilter[] };\n\n/** Either a bare condition (AND-ed) or an OR group. */\nexport type QueryCondition = QueryFilter | QueryFilterGroup;\n\nexport type Query = {\n /** Top-level conditions combined with AND. Use `{ or: [...] }` for disjunction. */\n filters?: QueryCondition[];\n orderBy?: { field: string; direction: \"asc\" | \"desc\" }[];\n limit?: number;\n /** Skip this many rows (offset pagination). */\n offset?: number;\n /**\n * Fields holding references to other documents to inline-resolve after the\n * primary fetch. Resolved by {@link resolveRelations}, never by the adapter.\n */\n populate?: string[];\n};\n\n/** Narrow a {@link QueryCondition} to an OR group. */\nexport function isFilterGroup(c: QueryCondition): c is QueryFilterGroup {\n return typeof c === \"object\" && c !== null && \"or\" in c;\n}\n\n/**\n * A reference to another document. Either self-describing (`{ collection, id }`)\n * or a bare id string resolved via a {@link RelationConfig}.\n */\nexport type Ref = { collection: string; id: string };\n\n/**\n * Maps a reference field name → the collection it points to. Needed only when\n * refs are stored as bare id strings (self-describing `Ref` objects don't need\n * it). Used by {@link resolveRelations}.\n */\nexport type RelationConfig = Record<string, { collection: string }>;\n\n/**\n * Persistence contract. Every backend (Firestore, Postgres, …) implements this;\n * the engine and the route factory only ever speak to this interface.\n */\nexport interface DataAdapter {\n fetchCollection<T = Record<string, any>>(\n collection: string,\n q?: Query,\n ): Promise<(T & { id: string })[]>;\n fetchById<T = Record<string, any>>(\n collection: string,\n id: string,\n ): Promise<(T & { id: string }) | null>;\n create<T = Record<string, any>>(\n collection: string,\n data: T,\n ): Promise<T & { id: string }>;\n createWithId<T = Record<string, any>>(\n collection: string,\n id: string,\n data: T,\n ): Promise<T & { id: string }>;\n update<T = Record<string, any>>(\n collection: string,\n id: string,\n data: Partial<T>,\n ): Promise<void>;\n upsert<T = Record<string, any>>(\n collection: string,\n id: string,\n data: Partial<T>,\n ): Promise<void>;\n delete(collection: string, id: string): Promise<void>;\n}\n\n/**\n * The identity resolved from an incoming request by an {@link AuthAdapter}.\n * Minimal by design: only `isAdmin` is meaningful to the default gate. Carry any\n * additional claims (roles, scopes, tenant, …) as extra keys and authorize on\n * them with a custom `authorize` predicate. `userId`/`email` are conventional\n * but optional.\n */\nexport interface AuthIdentity {\n isAdmin: boolean;\n userId?: string;\n email?: string;\n [claim: string]: unknown;\n}\n\n/**\n * Decides whether a resolved identity may perform admin actions. Defaults to\n * `identity.isAdmin === true`; override to gate on roles/scopes/etc.\n */\nexport type AuthorizeFn = (\n identity: AuthIdentity,\n req: Request,\n) => boolean | Promise<boolean>;\n\n/**\n * Server-side auth contract. Gates every admin API route. Return `null` to\n * reject outright; otherwise the gate's `authorize` predicate decides.\n */\nexport interface AuthAdapter {\n verifyRequest(req: Request): Promise<AuthIdentity | null>;\n}\n\n/**\n * Client half of storage: performs the browser-side upload and returns the\n * final URL. Has zero server dependencies, so it is safe to import in client\n * components. Built from e.g. `@dalgoridim/headless-cms/storage/cloudinary`.\n */\nexport interface ClientStorageAdapter {\n upload(file: File): Promise<{ url: string }>;\n}\n\n/**\n * Server half of storage: issues a presign / signature (or, for local storage,\n * writes the file). Mounted by the route factory at `${apiBasePath}/sign`.\n * Pulls in server-only SDKs, so it lives in a `/server` subpath. Built from e.g.\n * `@dalgoridim/headless-cms/storage/cloudinary/server`.\n */\nexport interface ServerStorageAdapter {\n sign(req: Request): Promise<unknown>;\n}\n\n/** Full two-sided contract (rarely needed; client and server halves are split). */\nexport interface StorageAdapter extends ClientStorageAdapter {\n sign?(req: Request): Promise<unknown>;\n}\n\n/**\n * Minimal client-side auth state the edit primitives depend on. Any auth\n * implementation (Firebase, NextAuth, custom) provides this via a context;\n * `@dalgoridim/headless-cms/auth/firebase/client` ships the default.\n */\nexport interface CmsAuthState {\n isAdmin: boolean;\n isEditing: boolean;\n toggleEdit: () => void;\n}\n"],"mappings":"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","names":[]}
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/adapters/postgres/index.ts","../../../src/types.ts"],"sourcesContent":["import { Pool } from \"pg\";\nimport { randomUUID } from \"node:crypto\";\nimport type {\n DataAdapter,\n Query,\n QueryCondition,\n QueryFilter,\n QueryFilterOp,\n} from \"../../types\";\nimport { isFilterGroup } from \"../../types\";\n\n/** Column type hints for a registered (typed) collection. */\nexport type ColumnType = \"text\" | \"int\" | \"float\" | \"bool\" | \"date\" | \"timestamptz\" | \"jsonb\";\n\nexport interface CollectionConfig {\n /** Physical table name. */\n table: string;\n /** Map of section field → SQL column type. Unlisted fields fall into `extra`. */\n columns: Record<string, ColumnType>;\n}\n\nexport interface PostgresAdapterConfig {\n /** Provide a connection string… */\n connectionString?: string;\n /** …or an existing pg Pool. */\n pool?: Pool;\n /**\n * Registry of typed collections. Anything not registered here is stored in\n * the shared JSONB `documents` table.\n */\n collections?: Record<string, CollectionConfig>;\n /** Name of the fallback JSONB table. Default `documents`. */\n documentsTable?: string;\n}\n\n/** Binary ops that render as `col <op> $n`. */\nconst BINARY_OP: Partial<Record<QueryFilterOp, string>> = {\n eq: \"=\",\n ne: \"<>\",\n lt: \"<\",\n lte: \"<=\",\n gt: \">\",\n gte: \">=\",\n};\n\nconst SQL_TYPE: Record<ColumnType, string> = {\n text: \"text\",\n int: \"integer\",\n float: \"double precision\",\n bool: \"boolean\",\n date: \"date\",\n timestamptz: \"timestamptz\",\n jsonb: \"jsonb\",\n};\n\n/**\n * Hybrid Postgres adapter. Unregistered collections live in a shared JSONB\n * `documents` table; registered collections map flat section fields onto typed\n * columns, with a JSONB `extra` column so unmapped fields are never dropped.\n */\nexport class PostgresDataAdapter implements DataAdapter {\n private readonly pool: Pool;\n private readonly collections: Record<string, CollectionConfig>;\n private readonly documentsTable: string;\n\n constructor(config: PostgresAdapterConfig = {}) {\n if (config.pool) {\n this.pool = config.pool;\n } else if (config.connectionString) {\n this.pool = new Pool({ connectionString: config.connectionString });\n } else {\n throw new Error(\n \"PostgresDataAdapter requires either `pool` or `connectionString`.\",\n );\n }\n this.collections = config.collections ?? {};\n this.documentsTable = config.documentsTable ?? \"documents\";\n }\n\n private cfg(collection: string): CollectionConfig | undefined {\n return this.collections[collection];\n }\n\n /** Create the documents table and every registered typed table if missing. */\n async migrate(): Promise<void> {\n await this.pool.query(`\n CREATE TABLE IF NOT EXISTS ${ident(this.documentsTable)} (\n id text NOT NULL,\n collection text NOT NULL,\n data jsonb NOT NULL,\n created_at timestamptz DEFAULT now(),\n updated_at timestamptz DEFAULT now(),\n PRIMARY KEY (collection, id)\n );\n `);\n await this.pool.query(\n `CREATE INDEX IF NOT EXISTS ${ident(this.documentsTable + \"_collection_created_idx\")}\n ON ${ident(this.documentsTable)} (collection, created_at DESC);`,\n );\n\n for (const cfg of Object.values(this.collections)) {\n const cols = Object.entries(cfg.columns)\n .map(([name, type]) => `${ident(name)} ${SQL_TYPE[type]}`)\n .join(\",\\n \");\n await this.pool.query(`\n CREATE TABLE IF NOT EXISTS ${ident(cfg.table)} (\n id text PRIMARY KEY,\n ${cols ? cols + \",\" : \"\"}\n extra jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz DEFAULT now(),\n updated_at timestamptz DEFAULT now()\n );\n `);\n }\n }\n\n // --- row <-> section mapping ------------------------------------------------\n\n /** Split a section into typed columns + leftover `extra`. */\n private toTypedRow(cfg: CollectionConfig, data: Record<string, unknown>) {\n const known: Record<string, unknown> = {};\n const extra: Record<string, unknown> = {};\n for (const [key, value] of Object.entries(data)) {\n if (key === \"id\" || key === \"collection\") continue;\n if (key in cfg.columns) known[key] = value;\n else extra[key] = value;\n }\n return { known, extra };\n }\n\n private fromTypedRow(\n collection: string,\n cfg: CollectionConfig,\n row: Record<string, unknown>,\n ) {\n const { id, extra, created_at, updated_at, ...rest } = row;\n void created_at;\n void updated_at;\n return {\n id: id as string,\n collection,\n ...(rest as Record<string, unknown>),\n ...((extra as Record<string, unknown>) ?? {}),\n };\n }\n\n // --- reads -----------------------------------------------------------------\n\n async fetchById<T = Record<string, unknown>>(\n collection: string,\n id: string,\n ): Promise<(T & { id: string }) | null> {\n const cfg = this.cfg(collection);\n if (cfg) {\n const { rows } = await this.pool.query(\n `SELECT * FROM ${ident(cfg.table)} WHERE id = $1`,\n [id],\n );\n if (!rows[0]) return null;\n return this.fromTypedRow(collection, cfg, rows[0]) as unknown as T & {\n id: string;\n };\n }\n const { rows } = await this.pool.query(\n `SELECT id, data FROM ${ident(this.documentsTable)} WHERE id = $1 AND collection = $2`,\n [id, collection],\n );\n if (!rows[0]) return null;\n return { id: rows[0].id, collection, ...rows[0].data } as unknown as T & {\n id: string;\n };\n }\n\n async fetchCollection<T = Record<string, unknown>>(\n collection: string,\n q?: Query,\n ): Promise<(T & { id: string })[]> {\n const cfg = this.cfg(collection);\n const params: unknown[] = [];\n const where: string[] = [];\n\n const colExpr = (field: string): string => {\n if (cfg) {\n return field in cfg.columns ? ident(field) : `extra->>${literal(field)}`;\n }\n return `data->>${literal(field)}`;\n };\n\n /** Render one field condition to SQL, pushing its bound params. */\n const renderFilter = (f: QueryFilter): string => {\n const col = colExpr(f.field);\n if (f.op === \"in\" || f.op === \"nin\") {\n params.push(f.value);\n const cmp = f.op === \"in\" ? \"= ANY\" : \"<> ALL\";\n return `${col} ${cmp}($${params.length})`;\n }\n if (f.op === \"contains\") {\n params.push(`%${String(f.value)}%`);\n return `${col} ILIKE $${params.length}`;\n }\n const op = BINARY_OP[f.op];\n if (!op) throw new Error(`Unsupported query op: ${f.op}`);\n params.push(f.value);\n return `${col} ${op} $${params.length}`;\n };\n\n /** Render an AND-level condition, expanding OR groups into `(a OR b)`. */\n const renderCondition = (c: QueryCondition): string => {\n if (isFilterGroup(c)) {\n if (!c.or.length) return \"TRUE\";\n return `(${c.or.map(renderFilter).join(\" OR \")})`;\n }\n return renderFilter(c);\n };\n\n if (!cfg) {\n params.push(collection);\n where.push(`collection = $${params.length}`);\n }\n\n for (const c of q?.filters ?? []) {\n where.push(renderCondition(c));\n }\n\n const orderBy =\n (q?.orderBy ?? []).map((o) => `${colExpr(o.field)} ${o.direction === \"asc\" ? \"ASC\" : \"DESC\"}`).join(\", \") ||\n \"created_at DESC\";\n\n const limit = q?.limit != null ? ` LIMIT ${Number(q.limit)}` : \"\";\n const offset = q?.offset != null ? ` OFFSET ${Number(q.offset)}` : \"\";\n const whereSql = where.length ? ` WHERE ${where.join(\" AND \")}` : \"\";\n const table = cfg ? cfg.table : this.documentsTable;\n const select = cfg ? \"*\" : \"id, data\";\n\n const { rows } = await this.pool.query(\n `SELECT ${select} FROM ${ident(table)}${whereSql} ORDER BY ${orderBy}${limit}${offset}`,\n params,\n );\n\n if (cfg) {\n return rows.map((r) => this.fromTypedRow(collection, cfg, r)) as unknown as (T & {\n id: string;\n })[];\n }\n return rows.map((r) => ({ id: r.id, collection, ...r.data })) as unknown as (T & {\n id: string;\n })[];\n }\n\n // --- writes ----------------------------------------------------------------\n\n async create<T = Record<string, unknown>>(\n collection: string,\n data: T,\n ): Promise<T & { id: string }> {\n return this.createWithId(collection, randomUUID(), data);\n }\n\n async createWithId<T = Record<string, unknown>>(\n collection: string,\n id: string,\n data: T,\n ): Promise<T & { id: string }> {\n const cfg = this.cfg(collection);\n const record = data as Record<string, unknown>;\n\n if (cfg) {\n const { known, extra } = this.toTypedRow(cfg, record);\n const cols = [\"id\", ...Object.keys(known), \"extra\"];\n const vals = [id, ...Object.values(known), JSON.stringify(extra)];\n const placeholders = cols.map((_, i) => `$${i + 1}`).join(\", \");\n await this.pool.query(\n `INSERT INTO ${ident(cfg.table)} (${cols.map(ident).join(\", \")})\n VALUES (${placeholders})`,\n vals,\n );\n } else {\n await this.pool.query(\n `INSERT INTO ${ident(this.documentsTable)} (id, collection, data)\n VALUES ($1, $2, $3)`,\n [id, collection, JSON.stringify(record)],\n );\n }\n return { id, ...(data as T) };\n }\n\n async update<T = Record<string, unknown>>(\n collection: string,\n id: string,\n data: Partial<T>,\n ): Promise<void> {\n await this.write(collection, id, data, \"update\");\n }\n\n async upsert<T = Record<string, unknown>>(\n collection: string,\n id: string,\n data: Partial<T>,\n ): Promise<void> {\n await this.write(collection, id, data, \"upsert\");\n }\n\n private async write(\n collection: string,\n id: string,\n data: Record<string, unknown>,\n mode: \"update\" | \"upsert\",\n ): Promise<void> {\n const cfg = this.cfg(collection);\n\n if (cfg) {\n const { known, extra } = this.toTypedRow(cfg, data);\n const setCols = Object.keys(known);\n const setExpr = setCols\n .map((c, i) => `${ident(c)} = $${i + 2}`)\n .join(\", \");\n // Merge into extra so unmapped fields aren't dropped.\n const extraIdx = setCols.length + 2;\n const sets = [\n setExpr,\n // Table-qualified: in ON CONFLICT DO UPDATE both the target table and the\n // `excluded` pseudo-relation expose `extra`, so a bare ref is ambiguous.\n `extra = ${ident(cfg.table)}.${ident(\"extra\")} || $${extraIdx}::jsonb`,\n `updated_at = now()`,\n ]\n .filter(Boolean)\n .join(\", \");\n const params = [id, ...Object.values(known), JSON.stringify(extra)];\n\n if (mode === \"upsert\") {\n const insertCols = [\"id\", ...setCols, \"extra\"];\n const insertVals = params;\n const placeholders = insertCols.map((_, i) => `$${i + 1}`).join(\", \");\n await this.pool.query(\n `INSERT INTO ${ident(cfg.table)} (${insertCols.map(ident).join(\", \")})\n VALUES (${placeholders})\n ON CONFLICT (id) DO UPDATE SET ${sets}`,\n insertVals,\n );\n } else {\n await this.pool.query(\n `UPDATE ${ident(cfg.table)} SET ${sets} WHERE id = $1`,\n params,\n );\n }\n return;\n }\n\n // JSONB documents table: shallow-merge the patch into `data`.\n if (mode === \"upsert\") {\n await this.pool.query(\n `INSERT INTO ${ident(this.documentsTable)} (id, collection, data)\n VALUES ($1, $2, $3)\n ON CONFLICT (collection, id) DO UPDATE\n SET data = ${ident(this.documentsTable)}.data || $3::jsonb,\n updated_at = now()`,\n [id, collection, JSON.stringify(data)],\n );\n } else {\n await this.pool.query(\n `UPDATE ${ident(this.documentsTable)}\n SET data = data || $2::jsonb, updated_at = now()\n WHERE id = $1 AND collection = $3`,\n [id, JSON.stringify(data), collection],\n );\n }\n }\n\n async delete(collection: string, id: string): Promise<void> {\n const cfg = this.cfg(collection);\n if (cfg) {\n await this.pool.query(`DELETE FROM ${ident(cfg.table)} WHERE id = $1`, [id]);\n } else {\n await this.pool.query(\n `DELETE FROM ${ident(this.documentsTable)} WHERE id = $1 AND collection = $2`,\n [id, collection],\n );\n }\n }\n}\n\n/** Quote a SQL identifier safely. */\nfunction ident(name: string): string {\n return `\"${name.replace(/\"/g, '\"\"')}\"`;\n}\n\n/** Quote a SQL string literal (used for static jsonb keys, never user values). */\nfunction literal(value: string): string {\n return `'${value.replace(/'/g, \"''\")}'`;\n}\n","/**\n * Shared, runtime-free types. Safe to import in any environment (client or\n * server) — this module pulls in zero runtime dependencies.\n */\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\n\n/**\n * Engine addressing fields. The engine needs to know an entity's `id` and which\n * `collection` it lives in to route saves; everything else is the consumer's own\n * shape. Kept separate from the user's content type `T` so we never force these\n * onto domain models — use {@link Editable}<YourType> when you want both.\n */\nexport interface EntityAddress {\n id: string;\n collection: string;\n}\n\n/**\n * The user's content type `T` decorated with the engine's addressing fields.\n * `T` is completely unconstrained — bring any shape; the engine only adds `id`\n * and `collection`.\n */\nexport type Editable<T = Record<string, any>> = T & EntityAddress;\n\n/**\n * A single editable record. Schemaless by design. Equivalent to\n * `Editable<Record<string, any>>`; kept as a named alias for the common case and\n * for backwards compatibility.\n */\nexport type Section = Editable;\n\nexport type SectionMap = Record<string, Section>;\n\n/**\n * The shape the engine holds in memory:\n * `{ [collection]: { [sectionKey]: Section } }`.\n */\nexport type NestedSections = {\n [collection: string]: {\n [sectionKey: string]: Section;\n };\n};\n\n/**\n * An image edit queued in the provider. The actual upload is deferred until\n * save. `file` is null when the user pasted an external URL (`isExternal`).\n */\nexport interface PendingImage {\n file: File | null;\n localUrl: string;\n sectionKey: string;\n fieldKey: string;\n collection: string;\n docId: string;\n isExternal?: boolean;\n}\n\n/**\n * Neutral query language. Keeps any backend's native query type (Firestore's\n * `Query`, SQL, etc.) from leaking through the engine.\n *\n * Op support is backend-dependent — see each adapter. `contains` is a\n * case-insensitive substring match; `in`/`nin` take an array value.\n */\nexport type QueryFilterOp =\n | \"eq\"\n | \"ne\"\n | \"lt\"\n | \"lte\"\n | \"gt\"\n | \"gte\"\n | \"in\"\n | \"nin\"\n | \"contains\";\n\n/** A single field condition. */\nexport type QueryFilter = { field: string; op: QueryFilterOp; value: unknown };\n\n/**\n * A disjunction: the inner filters are combined with OR. Sits alongside plain\n * filters in `Query.filters`, which are combined with AND at the top level.\n */\nexport type QueryFilterGroup = { or: QueryFilter[] };\n\n/** Either a bare condition (AND-ed) or an OR group. */\nexport type QueryCondition = QueryFilter | QueryFilterGroup;\n\nexport type Query = {\n /** Top-level conditions combined with AND. Use `{ or: [...] }` for disjunction. */\n filters?: QueryCondition[];\n orderBy?: { field: string; direction: \"asc\" | \"desc\" }[];\n limit?: number;\n /** Skip this many rows (offset pagination). */\n offset?: number;\n /**\n * Fields holding references to other documents to inline-resolve after the\n * primary fetch. Resolved by {@link resolveRelations}, never by the adapter.\n */\n populate?: string[];\n};\n\n/** Narrow a {@link QueryCondition} to an OR group. */\nexport function isFilterGroup(c: QueryCondition): c is QueryFilterGroup {\n return typeof c === \"object\" && c !== null && \"or\" in c;\n}\n\n/**\n * A reference to another document. Either self-describing (`{ collection, id }`)\n * or a bare id string resolved via a {@link RelationConfig}.\n */\nexport type Ref = { collection: string; id: string };\n\n/**\n * Maps a reference field name → the collection it points to. Needed only when\n * refs are stored as bare id strings (self-describing `Ref` objects don't need\n * it). Used by {@link resolveRelations}.\n */\nexport type RelationConfig = Record<string, { collection: string }>;\n\n/**\n * Persistence contract. Every backend (Firestore, Postgres, …) implements this;\n * the engine and the route factory only ever speak to this interface.\n */\nexport interface DataAdapter {\n fetchCollection<T = Record<string, any>>(\n collection: string,\n q?: Query,\n ): Promise<(T & { id: string })[]>;\n fetchById<T = Record<string, any>>(\n collection: string,\n id: string,\n ): Promise<(T & { id: string }) | null>;\n create<T = Record<string, any>>(\n collection: string,\n data: T,\n ): Promise<T & { id: string }>;\n createWithId<T = Record<string, any>>(\n collection: string,\n id: string,\n data: T,\n ): Promise<T & { id: string }>;\n update<T = Record<string, any>>(\n collection: string,\n id: string,\n data: Partial<T>,\n ): Promise<void>;\n upsert<T = Record<string, any>>(\n collection: string,\n id: string,\n data: Partial<T>,\n ): Promise<void>;\n delete(collection: string, id: string): Promise<void>;\n}\n\n/**\n * The identity resolved from an incoming request by an {@link AuthAdapter}.\n * Minimal by design: only `isAdmin` is meaningful to the default gate. Carry any\n * additional claims (roles, scopes, tenant, …) as extra keys and authorize on\n * them with a custom `authorize` predicate. `userId`/`email` are conventional\n * but optional.\n */\nexport interface AuthIdentity {\n isAdmin: boolean;\n userId?: string;\n email?: string;\n [claim: string]: unknown;\n}\n\n/**\n * Decides whether a resolved identity may perform admin actions. Defaults to\n * `identity.isAdmin === true`; override to gate on roles/scopes/etc.\n */\nexport type AuthorizeFn = (\n identity: AuthIdentity,\n req: Request,\n) => boolean | Promise<boolean>;\n\n/**\n * Server-side auth contract. Gates every admin API route. Return `null` to\n * reject outright; otherwise the gate's `authorize` predicate decides.\n */\nexport interface AuthAdapter {\n verifyRequest(req: Request): Promise<AuthIdentity | null>;\n}\n\n/**\n * Client half of storage: performs the browser-side upload and returns the\n * final URL. Has zero server dependencies, so it is safe to import in client\n * components. Built from e.g. `@dalgoridim/headless-cms/storage/cloudinary`.\n */\nexport interface ClientStorageAdapter {\n upload(file: File): Promise<{ url: string }>;\n}\n\n/**\n * Server half of storage: issues a presign / signature (or, for local storage,\n * writes the file). Mounted by the route factory at `${apiBasePath}/sign`.\n * Pulls in server-only SDKs, so it lives in a `/server` subpath. Built from e.g.\n * `@dalgoridim/headless-cms/storage/cloudinary/server`.\n */\nexport interface ServerStorageAdapter {\n sign(req: Request): Promise<unknown>;\n}\n\n/** Full two-sided contract (rarely needed; client and server halves are split). */\nexport interface StorageAdapter extends ClientStorageAdapter {\n sign?(req: Request): Promise<unknown>;\n}\n\n/**\n * Minimal client-side auth state the edit primitives depend on. Any auth\n * implementation (Firebase, NextAuth, custom) provides this via a context;\n * `@dalgoridim/headless-cms/auth/firebase/client` ships the default.\n */\nexport interface CmsAuthState {\n isAdmin: boolean;\n isEditing: boolean;\n toggleEdit: () => void;\n}\n"],"mappings":"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","names":["rows"]}
1
+ {"version":3,"sources":["../../../src/adapters/postgres/index.ts","../../../src/types.ts"],"sourcesContent":["import { Pool } from \"pg\";\nimport { randomUUID } from \"node:crypto\";\nimport type {\n DataAdapter,\n Query,\n QueryCondition,\n QueryFilter,\n QueryFilterOp,\n} from \"../../types\";\nimport { isFilterGroup } from \"../../types\";\n\n/** Column type hints for a registered (typed) collection. */\nexport type ColumnType = \"text\" | \"int\" | \"float\" | \"bool\" | \"date\" | \"timestamptz\" | \"jsonb\";\n\nexport interface CollectionConfig {\n /** Physical table name. */\n table: string;\n /** Map of section field → SQL column type. Unlisted fields fall into `extra`. */\n columns: Record<string, ColumnType>;\n}\n\nexport interface PostgresAdapterConfig {\n /** Provide a connection string… */\n connectionString?: string;\n /** …or an existing pg Pool. */\n pool?: Pool;\n /**\n * Registry of typed collections. Anything not registered here is stored in\n * the shared JSONB `documents` table.\n */\n collections?: Record<string, CollectionConfig>;\n /** Name of the fallback JSONB table. Default `documents`. */\n documentsTable?: string;\n}\n\n/** Binary ops that render as `col <op> $n`. */\nconst BINARY_OP: Partial<Record<QueryFilterOp, string>> = {\n eq: \"=\",\n ne: \"<>\",\n lt: \"<\",\n lte: \"<=\",\n gt: \">\",\n gte: \">=\",\n};\n\nconst SQL_TYPE: Record<ColumnType, string> = {\n text: \"text\",\n int: \"integer\",\n float: \"double precision\",\n bool: \"boolean\",\n date: \"date\",\n timestamptz: \"timestamptz\",\n jsonb: \"jsonb\",\n};\n\n/**\n * Hybrid Postgres adapter. Unregistered collections live in a shared JSONB\n * `documents` table; registered collections map flat section fields onto typed\n * columns, with a JSONB `extra` column so unmapped fields are never dropped.\n */\nexport class PostgresDataAdapter implements DataAdapter {\n private readonly pool: Pool;\n private readonly collections: Record<string, CollectionConfig>;\n private readonly documentsTable: string;\n\n constructor(config: PostgresAdapterConfig = {}) {\n if (config.pool) {\n this.pool = config.pool;\n } else if (config.connectionString) {\n this.pool = new Pool({ connectionString: config.connectionString });\n } else {\n throw new Error(\n \"PostgresDataAdapter requires either `pool` or `connectionString`.\",\n );\n }\n this.collections = config.collections ?? {};\n this.documentsTable = config.documentsTable ?? \"documents\";\n }\n\n private cfg(collection: string): CollectionConfig | undefined {\n return this.collections[collection];\n }\n\n /** Create the documents table and every registered typed table if missing. */\n async migrate(): Promise<void> {\n await this.pool.query(`\n CREATE TABLE IF NOT EXISTS ${ident(this.documentsTable)} (\n id text NOT NULL,\n collection text NOT NULL,\n data jsonb NOT NULL,\n created_at timestamptz DEFAULT now(),\n updated_at timestamptz DEFAULT now(),\n PRIMARY KEY (collection, id)\n );\n `);\n await this.pool.query(\n `CREATE INDEX IF NOT EXISTS ${ident(this.documentsTable + \"_collection_created_idx\")}\n ON ${ident(this.documentsTable)} (collection, created_at DESC);`,\n );\n\n for (const cfg of Object.values(this.collections)) {\n const cols = Object.entries(cfg.columns)\n .map(([name, type]) => `${ident(name)} ${SQL_TYPE[type]}`)\n .join(\",\\n \");\n await this.pool.query(`\n CREATE TABLE IF NOT EXISTS ${ident(cfg.table)} (\n id text PRIMARY KEY,\n ${cols ? cols + \",\" : \"\"}\n extra jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz DEFAULT now(),\n updated_at timestamptz DEFAULT now()\n );\n `);\n }\n }\n\n // --- row <-> section mapping ------------------------------------------------\n\n /** Split a section into typed columns + leftover `extra`. */\n private toTypedRow(cfg: CollectionConfig, data: Record<string, unknown>) {\n const known: Record<string, unknown> = {};\n const extra: Record<string, unknown> = {};\n for (const [key, value] of Object.entries(data)) {\n if (key === \"id\" || key === \"collection\") continue;\n if (key in cfg.columns) known[key] = value;\n else extra[key] = value;\n }\n return { known, extra };\n }\n\n private fromTypedRow(\n collection: string,\n cfg: CollectionConfig,\n row: Record<string, unknown>,\n ) {\n const { id, extra, created_at, updated_at, ...rest } = row;\n void created_at;\n void updated_at;\n return {\n id: id as string,\n collection,\n ...(rest as Record<string, unknown>),\n ...((extra as Record<string, unknown>) ?? {}),\n };\n }\n\n // --- reads -----------------------------------------------------------------\n\n async fetchById<T = Record<string, unknown>>(\n collection: string,\n id: string,\n ): Promise<(T & { id: string }) | null> {\n const cfg = this.cfg(collection);\n if (cfg) {\n const { rows } = await this.pool.query(\n `SELECT * FROM ${ident(cfg.table)} WHERE id = $1`,\n [id],\n );\n if (!rows[0]) return null;\n return this.fromTypedRow(collection, cfg, rows[0]) as unknown as T & {\n id: string;\n };\n }\n const { rows } = await this.pool.query(\n `SELECT id, data FROM ${ident(this.documentsTable)} WHERE id = $1 AND collection = $2`,\n [id, collection],\n );\n if (!rows[0]) return null;\n return { id: rows[0].id, collection, ...rows[0].data } as unknown as T & {\n id: string;\n };\n }\n\n async fetchCollection<T = Record<string, unknown>>(\n collection: string,\n q?: Query,\n ): Promise<(T & { id: string })[]> {\n const cfg = this.cfg(collection);\n const params: unknown[] = [];\n const where: string[] = [];\n\n const colExpr = (field: string): string => {\n if (cfg) {\n return field in cfg.columns ? ident(field) : `extra->>${literal(field)}`;\n }\n return `data->>${literal(field)}`;\n };\n\n /** Render one field condition to SQL, pushing its bound params. */\n const renderFilter = (f: QueryFilter): string => {\n const col = colExpr(f.field);\n if (f.op === \"in\" || f.op === \"nin\") {\n params.push(f.value);\n const cmp = f.op === \"in\" ? \"= ANY\" : \"<> ALL\";\n return `${col} ${cmp}($${params.length})`;\n }\n if (f.op === \"contains\") {\n params.push(`%${String(f.value)}%`);\n return `${col} ILIKE $${params.length}`;\n }\n const op = BINARY_OP[f.op];\n if (!op) throw new Error(`Unsupported query op: ${f.op}`);\n params.push(f.value);\n return `${col} ${op} $${params.length}`;\n };\n\n /** Render an AND-level condition, expanding OR groups into `(a OR b)`. */\n const renderCondition = (c: QueryCondition): string => {\n if (isFilterGroup(c)) {\n if (!c.or.length) return \"TRUE\";\n return `(${c.or.map(renderFilter).join(\" OR \")})`;\n }\n return renderFilter(c);\n };\n\n if (!cfg) {\n params.push(collection);\n where.push(`collection = $${params.length}`);\n }\n\n for (const c of q?.filters ?? []) {\n where.push(renderCondition(c));\n }\n\n const orderBy =\n (q?.orderBy ?? []).map((o) => `${colExpr(o.field)} ${o.direction === \"asc\" ? \"ASC\" : \"DESC\"}`).join(\", \") ||\n \"created_at DESC\";\n\n const limit = q?.limit != null ? ` LIMIT ${Number(q.limit)}` : \"\";\n const offset = q?.offset != null ? ` OFFSET ${Number(q.offset)}` : \"\";\n const whereSql = where.length ? ` WHERE ${where.join(\" AND \")}` : \"\";\n const table = cfg ? cfg.table : this.documentsTable;\n const select = cfg ? \"*\" : \"id, data\";\n\n const { rows } = await this.pool.query(\n `SELECT ${select} FROM ${ident(table)}${whereSql} ORDER BY ${orderBy}${limit}${offset}`,\n params,\n );\n\n if (cfg) {\n return rows.map((r) => this.fromTypedRow(collection, cfg, r)) as unknown as (T & {\n id: string;\n })[];\n }\n return rows.map((r) => ({ id: r.id, collection, ...r.data })) as unknown as (T & {\n id: string;\n })[];\n }\n\n // --- writes ----------------------------------------------------------------\n\n async create<T = Record<string, unknown>>(\n collection: string,\n data: T,\n ): Promise<T & { id: string }> {\n return this.createWithId(collection, randomUUID(), data);\n }\n\n async createWithId<T = Record<string, unknown>>(\n collection: string,\n id: string,\n data: T,\n ): Promise<T & { id: string }> {\n const cfg = this.cfg(collection);\n const record = data as Record<string, unknown>;\n\n if (cfg) {\n const { known, extra } = this.toTypedRow(cfg, record);\n const cols = [\"id\", ...Object.keys(known), \"extra\"];\n const vals = [id, ...Object.values(known), JSON.stringify(extra)];\n const placeholders = cols.map((_, i) => `$${i + 1}`).join(\", \");\n await this.pool.query(\n `INSERT INTO ${ident(cfg.table)} (${cols.map(ident).join(\", \")})\n VALUES (${placeholders})`,\n vals,\n );\n } else {\n await this.pool.query(\n `INSERT INTO ${ident(this.documentsTable)} (id, collection, data)\n VALUES ($1, $2, $3)`,\n [id, collection, JSON.stringify(record)],\n );\n }\n return { id, ...(data as T) };\n }\n\n async update<T = Record<string, unknown>>(\n collection: string,\n id: string,\n data: Partial<T>,\n ): Promise<void> {\n await this.write(collection, id, data, \"update\");\n }\n\n async upsert<T = Record<string, unknown>>(\n collection: string,\n id: string,\n data: Partial<T>,\n ): Promise<void> {\n await this.write(collection, id, data, \"upsert\");\n }\n\n private async write(\n collection: string,\n id: string,\n data: Record<string, unknown>,\n mode: \"update\" | \"upsert\",\n ): Promise<void> {\n const cfg = this.cfg(collection);\n\n if (cfg) {\n const { known, extra } = this.toTypedRow(cfg, data);\n const setCols = Object.keys(known);\n const setExpr = setCols\n .map((c, i) => `${ident(c)} = $${i + 2}`)\n .join(\", \");\n // Merge into extra so unmapped fields aren't dropped.\n const extraIdx = setCols.length + 2;\n const sets = [\n setExpr,\n // Table-qualified: in ON CONFLICT DO UPDATE both the target table and the\n // `excluded` pseudo-relation expose `extra`, so a bare ref is ambiguous.\n `extra = ${ident(cfg.table)}.${ident(\"extra\")} || $${extraIdx}::jsonb`,\n `updated_at = now()`,\n ]\n .filter(Boolean)\n .join(\", \");\n const params = [id, ...Object.values(known), JSON.stringify(extra)];\n\n if (mode === \"upsert\") {\n const insertCols = [\"id\", ...setCols, \"extra\"];\n const insertVals = params;\n const placeholders = insertCols.map((_, i) => `$${i + 1}`).join(\", \");\n await this.pool.query(\n `INSERT INTO ${ident(cfg.table)} (${insertCols.map(ident).join(\", \")})\n VALUES (${placeholders})\n ON CONFLICT (id) DO UPDATE SET ${sets}`,\n insertVals,\n );\n } else {\n await this.pool.query(\n `UPDATE ${ident(cfg.table)} SET ${sets} WHERE id = $1`,\n params,\n );\n }\n return;\n }\n\n // JSONB documents table: shallow-merge the patch into `data`.\n if (mode === \"upsert\") {\n await this.pool.query(\n `INSERT INTO ${ident(this.documentsTable)} (id, collection, data)\n VALUES ($1, $2, $3)\n ON CONFLICT (collection, id) DO UPDATE\n SET data = ${ident(this.documentsTable)}.data || $3::jsonb,\n updated_at = now()`,\n [id, collection, JSON.stringify(data)],\n );\n } else {\n await this.pool.query(\n `UPDATE ${ident(this.documentsTable)}\n SET data = data || $2::jsonb, updated_at = now()\n WHERE id = $1 AND collection = $3`,\n [id, JSON.stringify(data), collection],\n );\n }\n }\n\n async delete(collection: string, id: string): Promise<void> {\n const cfg = this.cfg(collection);\n if (cfg) {\n await this.pool.query(`DELETE FROM ${ident(cfg.table)} WHERE id = $1`, [id]);\n } else {\n await this.pool.query(\n `DELETE FROM ${ident(this.documentsTable)} WHERE id = $1 AND collection = $2`,\n [id, collection],\n );\n }\n }\n}\n\n/** Quote a SQL identifier safely. */\nfunction ident(name: string): string {\n return `\"${name.replace(/\"/g, '\"\"')}\"`;\n}\n\n/** Quote a SQL string literal (used for static jsonb keys, never user values). */\nfunction literal(value: string): string {\n return `'${value.replace(/'/g, \"''\")}'`;\n}\n","/**\n * Shared, runtime-free types. Safe to import in any environment (client or\n * server) — this module pulls in zero runtime dependencies.\n */\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\n\n/**\n * Engine addressing fields. The engine needs to know an entity's `id` and which\n * `collection` it lives in to route saves; everything else is the consumer's own\n * shape. Kept separate from the user's content type `T` so we never force these\n * onto domain models — use {@link Editable}<YourType> when you want both.\n */\nexport interface EntityAddress {\n id: string;\n collection: string;\n}\n\n/**\n * The user's content type `T` decorated with the engine's addressing fields.\n * `T` is completely unconstrained — bring any shape; the engine only adds `id`\n * and `collection`.\n */\nexport type Editable<T = Record<string, any>> = T & EntityAddress;\n\n/**\n * A single editable record. Schemaless by design. Equivalent to\n * `Editable<Record<string, any>>`; kept as a named alias for the common case and\n * for backwards compatibility.\n */\nexport type Section = Editable;\n\nexport type SectionMap = Record<string, Section>;\n\n/**\n * The shape the engine holds in memory:\n * `{ [collection]: { [sectionKey]: Section } }`.\n */\nexport type NestedSections = {\n [collection: string]: {\n [sectionKey: string]: Section;\n };\n};\n\n/**\n * A single item in an editable collection list (e.g. a project or a tool). Just\n * a record with a stable `id`. Used by the client provider's collection ops\n * (create / delete / reorder) which manage *which* items exist and their order,\n * complementing {@link Section}s which manage a single item's editable fields.\n */\nexport type CollectionItem = Record<string, unknown> & { id: string };\n\n/**\n * An image edit queued in the provider. The actual upload is deferred until\n * save. `file` is null when the user pasted an external URL (`isExternal`).\n */\nexport interface PendingImage {\n file: File | null;\n localUrl: string;\n sectionKey: string;\n fieldKey: string;\n collection: string;\n docId: string;\n isExternal?: boolean;\n}\n\n/**\n * Neutral query language. Keeps any backend's native query type (Firestore's\n * `Query`, SQL, etc.) from leaking through the engine.\n *\n * Op support is backend-dependent — see each adapter. `contains` is a\n * case-insensitive substring match; `in`/`nin` take an array value.\n */\nexport type QueryFilterOp =\n | \"eq\"\n | \"ne\"\n | \"lt\"\n | \"lte\"\n | \"gt\"\n | \"gte\"\n | \"in\"\n | \"nin\"\n | \"contains\";\n\n/** A single field condition. */\nexport type QueryFilter = { field: string; op: QueryFilterOp; value: unknown };\n\n/**\n * A disjunction: the inner filters are combined with OR. Sits alongside plain\n * filters in `Query.filters`, which are combined with AND at the top level.\n */\nexport type QueryFilterGroup = { or: QueryFilter[] };\n\n/** Either a bare condition (AND-ed) or an OR group. */\nexport type QueryCondition = QueryFilter | QueryFilterGroup;\n\nexport type Query = {\n /** Top-level conditions combined with AND. Use `{ or: [...] }` for disjunction. */\n filters?: QueryCondition[];\n orderBy?: { field: string; direction: \"asc\" | \"desc\" }[];\n limit?: number;\n /** Skip this many rows (offset pagination). */\n offset?: number;\n /**\n * Fields holding references to other documents to inline-resolve after the\n * primary fetch. Resolved by {@link resolveRelations}, never by the adapter.\n */\n populate?: string[];\n};\n\n/** Narrow a {@link QueryCondition} to an OR group. */\nexport function isFilterGroup(c: QueryCondition): c is QueryFilterGroup {\n return typeof c === \"object\" && c !== null && \"or\" in c;\n}\n\n/**\n * A reference to another document. Either self-describing (`{ collection, id }`)\n * or a bare id string resolved via a {@link RelationConfig}.\n */\nexport type Ref = { collection: string; id: string };\n\n/**\n * Maps a reference field name → the collection it points to. Needed only when\n * refs are stored as bare id strings (self-describing `Ref` objects don't need\n * it). Used by {@link resolveRelations}.\n */\nexport type RelationConfig = Record<string, { collection: string }>;\n\n/**\n * Persistence contract. Every backend (Firestore, Postgres, …) implements this;\n * the engine and the route factory only ever speak to this interface.\n */\nexport interface DataAdapter {\n fetchCollection<T = Record<string, any>>(\n collection: string,\n q?: Query,\n ): Promise<(T & { id: string })[]>;\n fetchById<T = Record<string, any>>(\n collection: string,\n id: string,\n ): Promise<(T & { id: string }) | null>;\n create<T = Record<string, any>>(\n collection: string,\n data: T,\n ): Promise<T & { id: string }>;\n createWithId<T = Record<string, any>>(\n collection: string,\n id: string,\n data: T,\n ): Promise<T & { id: string }>;\n update<T = Record<string, any>>(\n collection: string,\n id: string,\n data: Partial<T>,\n ): Promise<void>;\n upsert<T = Record<string, any>>(\n collection: string,\n id: string,\n data: Partial<T>,\n ): Promise<void>;\n delete(collection: string, id: string): Promise<void>;\n}\n\n/**\n * The identity resolved from an incoming request by an {@link AuthAdapter}.\n * Minimal by design: only `isAdmin` is meaningful to the default gate. Carry any\n * additional claims (roles, scopes, tenant, …) as extra keys and authorize on\n * them with a custom `authorize` predicate. `userId`/`email` are conventional\n * but optional.\n */\nexport interface AuthIdentity {\n isAdmin: boolean;\n userId?: string;\n email?: string;\n [claim: string]: unknown;\n}\n\n/**\n * Decides whether a resolved identity may perform admin actions. Defaults to\n * `identity.isAdmin === true`; override to gate on roles/scopes/etc.\n */\nexport type AuthorizeFn = (\n identity: AuthIdentity,\n req: Request,\n) => boolean | Promise<boolean>;\n\n/**\n * Server-side auth contract. Gates every admin API route. Return `null` to\n * reject outright; otherwise the gate's `authorize` predicate decides.\n */\nexport interface AuthAdapter {\n verifyRequest(req: Request): Promise<AuthIdentity | null>;\n}\n\n/**\n * Client half of storage: performs the browser-side upload and returns the\n * final URL. Has zero server dependencies, so it is safe to import in client\n * components. Built from e.g. `@dalgoridim/headless-cms/storage/cloudinary`.\n */\nexport interface ClientStorageAdapter {\n upload(file: File): Promise<{ url: string }>;\n}\n\n/**\n * Server half of storage: issues a presign / signature (or, for local storage,\n * writes the file). Mounted by the route factory at `${apiBasePath}/sign`.\n * Pulls in server-only SDKs, so it lives in a `/server` subpath. Built from e.g.\n * `@dalgoridim/headless-cms/storage/cloudinary/server`.\n */\nexport interface ServerStorageAdapter {\n sign(req: Request): Promise<unknown>;\n}\n\n/** Full two-sided contract (rarely needed; client and server halves are split). */\nexport interface StorageAdapter extends ClientStorageAdapter {\n sign?(req: Request): Promise<unknown>;\n}\n\n/**\n * Minimal client-side auth state the edit primitives depend on. Any auth\n * implementation (Firebase, NextAuth, custom) provides this via a context;\n * `@dalgoridim/headless-cms/auth/firebase/client` ships the default.\n */\nexport interface CmsAuthState {\n isAdmin: boolean;\n isEditing: boolean;\n toggleEdit: () => void;\n}\n"],"mappings":"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","names":["rows"]}
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/adapters/postgres/index.ts","../../../src/types.ts"],"sourcesContent":["import { Pool } from \"pg\";\nimport { randomUUID } from \"node:crypto\";\nimport type {\n DataAdapter,\n Query,\n QueryCondition,\n QueryFilter,\n QueryFilterOp,\n} from \"../../types\";\nimport { isFilterGroup } from \"../../types\";\n\n/** Column type hints for a registered (typed) collection. */\nexport type ColumnType = \"text\" | \"int\" | \"float\" | \"bool\" | \"date\" | \"timestamptz\" | \"jsonb\";\n\nexport interface CollectionConfig {\n /** Physical table name. */\n table: string;\n /** Map of section field → SQL column type. Unlisted fields fall into `extra`. */\n columns: Record<string, ColumnType>;\n}\n\nexport interface PostgresAdapterConfig {\n /** Provide a connection string… */\n connectionString?: string;\n /** …or an existing pg Pool. */\n pool?: Pool;\n /**\n * Registry of typed collections. Anything not registered here is stored in\n * the shared JSONB `documents` table.\n */\n collections?: Record<string, CollectionConfig>;\n /** Name of the fallback JSONB table. Default `documents`. */\n documentsTable?: string;\n}\n\n/** Binary ops that render as `col <op> $n`. */\nconst BINARY_OP: Partial<Record<QueryFilterOp, string>> = {\n eq: \"=\",\n ne: \"<>\",\n lt: \"<\",\n lte: \"<=\",\n gt: \">\",\n gte: \">=\",\n};\n\nconst SQL_TYPE: Record<ColumnType, string> = {\n text: \"text\",\n int: \"integer\",\n float: \"double precision\",\n bool: \"boolean\",\n date: \"date\",\n timestamptz: \"timestamptz\",\n jsonb: \"jsonb\",\n};\n\n/**\n * Hybrid Postgres adapter. Unregistered collections live in a shared JSONB\n * `documents` table; registered collections map flat section fields onto typed\n * columns, with a JSONB `extra` column so unmapped fields are never dropped.\n */\nexport class PostgresDataAdapter implements DataAdapter {\n private readonly pool: Pool;\n private readonly collections: Record<string, CollectionConfig>;\n private readonly documentsTable: string;\n\n constructor(config: PostgresAdapterConfig = {}) {\n if (config.pool) {\n this.pool = config.pool;\n } else if (config.connectionString) {\n this.pool = new Pool({ connectionString: config.connectionString });\n } else {\n throw new Error(\n \"PostgresDataAdapter requires either `pool` or `connectionString`.\",\n );\n }\n this.collections = config.collections ?? {};\n this.documentsTable = config.documentsTable ?? \"documents\";\n }\n\n private cfg(collection: string): CollectionConfig | undefined {\n return this.collections[collection];\n }\n\n /** Create the documents table and every registered typed table if missing. */\n async migrate(): Promise<void> {\n await this.pool.query(`\n CREATE TABLE IF NOT EXISTS ${ident(this.documentsTable)} (\n id text NOT NULL,\n collection text NOT NULL,\n data jsonb NOT NULL,\n created_at timestamptz DEFAULT now(),\n updated_at timestamptz DEFAULT now(),\n PRIMARY KEY (collection, id)\n );\n `);\n await this.pool.query(\n `CREATE INDEX IF NOT EXISTS ${ident(this.documentsTable + \"_collection_created_idx\")}\n ON ${ident(this.documentsTable)} (collection, created_at DESC);`,\n );\n\n for (const cfg of Object.values(this.collections)) {\n const cols = Object.entries(cfg.columns)\n .map(([name, type]) => `${ident(name)} ${SQL_TYPE[type]}`)\n .join(\",\\n \");\n await this.pool.query(`\n CREATE TABLE IF NOT EXISTS ${ident(cfg.table)} (\n id text PRIMARY KEY,\n ${cols ? cols + \",\" : \"\"}\n extra jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz DEFAULT now(),\n updated_at timestamptz DEFAULT now()\n );\n `);\n }\n }\n\n // --- row <-> section mapping ------------------------------------------------\n\n /** Split a section into typed columns + leftover `extra`. */\n private toTypedRow(cfg: CollectionConfig, data: Record<string, unknown>) {\n const known: Record<string, unknown> = {};\n const extra: Record<string, unknown> = {};\n for (const [key, value] of Object.entries(data)) {\n if (key === \"id\" || key === \"collection\") continue;\n if (key in cfg.columns) known[key] = value;\n else extra[key] = value;\n }\n return { known, extra };\n }\n\n private fromTypedRow(\n collection: string,\n cfg: CollectionConfig,\n row: Record<string, unknown>,\n ) {\n const { id, extra, created_at, updated_at, ...rest } = row;\n void created_at;\n void updated_at;\n return {\n id: id as string,\n collection,\n ...(rest as Record<string, unknown>),\n ...((extra as Record<string, unknown>) ?? {}),\n };\n }\n\n // --- reads -----------------------------------------------------------------\n\n async fetchById<T = Record<string, unknown>>(\n collection: string,\n id: string,\n ): Promise<(T & { id: string }) | null> {\n const cfg = this.cfg(collection);\n if (cfg) {\n const { rows } = await this.pool.query(\n `SELECT * FROM ${ident(cfg.table)} WHERE id = $1`,\n [id],\n );\n if (!rows[0]) return null;\n return this.fromTypedRow(collection, cfg, rows[0]) as unknown as T & {\n id: string;\n };\n }\n const { rows } = await this.pool.query(\n `SELECT id, data FROM ${ident(this.documentsTable)} WHERE id = $1 AND collection = $2`,\n [id, collection],\n );\n if (!rows[0]) return null;\n return { id: rows[0].id, collection, ...rows[0].data } as unknown as T & {\n id: string;\n };\n }\n\n async fetchCollection<T = Record<string, unknown>>(\n collection: string,\n q?: Query,\n ): Promise<(T & { id: string })[]> {\n const cfg = this.cfg(collection);\n const params: unknown[] = [];\n const where: string[] = [];\n\n const colExpr = (field: string): string => {\n if (cfg) {\n return field in cfg.columns ? ident(field) : `extra->>${literal(field)}`;\n }\n return `data->>${literal(field)}`;\n };\n\n /** Render one field condition to SQL, pushing its bound params. */\n const renderFilter = (f: QueryFilter): string => {\n const col = colExpr(f.field);\n if (f.op === \"in\" || f.op === \"nin\") {\n params.push(f.value);\n const cmp = f.op === \"in\" ? \"= ANY\" : \"<> ALL\";\n return `${col} ${cmp}($${params.length})`;\n }\n if (f.op === \"contains\") {\n params.push(`%${String(f.value)}%`);\n return `${col} ILIKE $${params.length}`;\n }\n const op = BINARY_OP[f.op];\n if (!op) throw new Error(`Unsupported query op: ${f.op}`);\n params.push(f.value);\n return `${col} ${op} $${params.length}`;\n };\n\n /** Render an AND-level condition, expanding OR groups into `(a OR b)`. */\n const renderCondition = (c: QueryCondition): string => {\n if (isFilterGroup(c)) {\n if (!c.or.length) return \"TRUE\";\n return `(${c.or.map(renderFilter).join(\" OR \")})`;\n }\n return renderFilter(c);\n };\n\n if (!cfg) {\n params.push(collection);\n where.push(`collection = $${params.length}`);\n }\n\n for (const c of q?.filters ?? []) {\n where.push(renderCondition(c));\n }\n\n const orderBy =\n (q?.orderBy ?? []).map((o) => `${colExpr(o.field)} ${o.direction === \"asc\" ? \"ASC\" : \"DESC\"}`).join(\", \") ||\n \"created_at DESC\";\n\n const limit = q?.limit != null ? ` LIMIT ${Number(q.limit)}` : \"\";\n const offset = q?.offset != null ? ` OFFSET ${Number(q.offset)}` : \"\";\n const whereSql = where.length ? ` WHERE ${where.join(\" AND \")}` : \"\";\n const table = cfg ? cfg.table : this.documentsTable;\n const select = cfg ? \"*\" : \"id, data\";\n\n const { rows } = await this.pool.query(\n `SELECT ${select} FROM ${ident(table)}${whereSql} ORDER BY ${orderBy}${limit}${offset}`,\n params,\n );\n\n if (cfg) {\n return rows.map((r) => this.fromTypedRow(collection, cfg, r)) as unknown as (T & {\n id: string;\n })[];\n }\n return rows.map((r) => ({ id: r.id, collection, ...r.data })) as unknown as (T & {\n id: string;\n })[];\n }\n\n // --- writes ----------------------------------------------------------------\n\n async create<T = Record<string, unknown>>(\n collection: string,\n data: T,\n ): Promise<T & { id: string }> {\n return this.createWithId(collection, randomUUID(), data);\n }\n\n async createWithId<T = Record<string, unknown>>(\n collection: string,\n id: string,\n data: T,\n ): Promise<T & { id: string }> {\n const cfg = this.cfg(collection);\n const record = data as Record<string, unknown>;\n\n if (cfg) {\n const { known, extra } = this.toTypedRow(cfg, record);\n const cols = [\"id\", ...Object.keys(known), \"extra\"];\n const vals = [id, ...Object.values(known), JSON.stringify(extra)];\n const placeholders = cols.map((_, i) => `$${i + 1}`).join(\", \");\n await this.pool.query(\n `INSERT INTO ${ident(cfg.table)} (${cols.map(ident).join(\", \")})\n VALUES (${placeholders})`,\n vals,\n );\n } else {\n await this.pool.query(\n `INSERT INTO ${ident(this.documentsTable)} (id, collection, data)\n VALUES ($1, $2, $3)`,\n [id, collection, JSON.stringify(record)],\n );\n }\n return { id, ...(data as T) };\n }\n\n async update<T = Record<string, unknown>>(\n collection: string,\n id: string,\n data: Partial<T>,\n ): Promise<void> {\n await this.write(collection, id, data, \"update\");\n }\n\n async upsert<T = Record<string, unknown>>(\n collection: string,\n id: string,\n data: Partial<T>,\n ): Promise<void> {\n await this.write(collection, id, data, \"upsert\");\n }\n\n private async write(\n collection: string,\n id: string,\n data: Record<string, unknown>,\n mode: \"update\" | \"upsert\",\n ): Promise<void> {\n const cfg = this.cfg(collection);\n\n if (cfg) {\n const { known, extra } = this.toTypedRow(cfg, data);\n const setCols = Object.keys(known);\n const setExpr = setCols\n .map((c, i) => `${ident(c)} = $${i + 2}`)\n .join(\", \");\n // Merge into extra so unmapped fields aren't dropped.\n const extraIdx = setCols.length + 2;\n const sets = [\n setExpr,\n // Table-qualified: in ON CONFLICT DO UPDATE both the target table and the\n // `excluded` pseudo-relation expose `extra`, so a bare ref is ambiguous.\n `extra = ${ident(cfg.table)}.${ident(\"extra\")} || $${extraIdx}::jsonb`,\n `updated_at = now()`,\n ]\n .filter(Boolean)\n .join(\", \");\n const params = [id, ...Object.values(known), JSON.stringify(extra)];\n\n if (mode === \"upsert\") {\n const insertCols = [\"id\", ...setCols, \"extra\"];\n const insertVals = params;\n const placeholders = insertCols.map((_, i) => `$${i + 1}`).join(\", \");\n await this.pool.query(\n `INSERT INTO ${ident(cfg.table)} (${insertCols.map(ident).join(\", \")})\n VALUES (${placeholders})\n ON CONFLICT (id) DO UPDATE SET ${sets}`,\n insertVals,\n );\n } else {\n await this.pool.query(\n `UPDATE ${ident(cfg.table)} SET ${sets} WHERE id = $1`,\n params,\n );\n }\n return;\n }\n\n // JSONB documents table: shallow-merge the patch into `data`.\n if (mode === \"upsert\") {\n await this.pool.query(\n `INSERT INTO ${ident(this.documentsTable)} (id, collection, data)\n VALUES ($1, $2, $3)\n ON CONFLICT (collection, id) DO UPDATE\n SET data = ${ident(this.documentsTable)}.data || $3::jsonb,\n updated_at = now()`,\n [id, collection, JSON.stringify(data)],\n );\n } else {\n await this.pool.query(\n `UPDATE ${ident(this.documentsTable)}\n SET data = data || $2::jsonb, updated_at = now()\n WHERE id = $1 AND collection = $3`,\n [id, JSON.stringify(data), collection],\n );\n }\n }\n\n async delete(collection: string, id: string): Promise<void> {\n const cfg = this.cfg(collection);\n if (cfg) {\n await this.pool.query(`DELETE FROM ${ident(cfg.table)} WHERE id = $1`, [id]);\n } else {\n await this.pool.query(\n `DELETE FROM ${ident(this.documentsTable)} WHERE id = $1 AND collection = $2`,\n [id, collection],\n );\n }\n }\n}\n\n/** Quote a SQL identifier safely. */\nfunction ident(name: string): string {\n return `\"${name.replace(/\"/g, '\"\"')}\"`;\n}\n\n/** Quote a SQL string literal (used for static jsonb keys, never user values). */\nfunction literal(value: string): string {\n return `'${value.replace(/'/g, \"''\")}'`;\n}\n","/**\n * Shared, runtime-free types. Safe to import in any environment (client or\n * server) — this module pulls in zero runtime dependencies.\n */\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\n\n/**\n * Engine addressing fields. The engine needs to know an entity's `id` and which\n * `collection` it lives in to route saves; everything else is the consumer's own\n * shape. Kept separate from the user's content type `T` so we never force these\n * onto domain models — use {@link Editable}<YourType> when you want both.\n */\nexport interface EntityAddress {\n id: string;\n collection: string;\n}\n\n/**\n * The user's content type `T` decorated with the engine's addressing fields.\n * `T` is completely unconstrained — bring any shape; the engine only adds `id`\n * and `collection`.\n */\nexport type Editable<T = Record<string, any>> = T & EntityAddress;\n\n/**\n * A single editable record. Schemaless by design. Equivalent to\n * `Editable<Record<string, any>>`; kept as a named alias for the common case and\n * for backwards compatibility.\n */\nexport type Section = Editable;\n\nexport type SectionMap = Record<string, Section>;\n\n/**\n * The shape the engine holds in memory:\n * `{ [collection]: { [sectionKey]: Section } }`.\n */\nexport type NestedSections = {\n [collection: string]: {\n [sectionKey: string]: Section;\n };\n};\n\n/**\n * An image edit queued in the provider. The actual upload is deferred until\n * save. `file` is null when the user pasted an external URL (`isExternal`).\n */\nexport interface PendingImage {\n file: File | null;\n localUrl: string;\n sectionKey: string;\n fieldKey: string;\n collection: string;\n docId: string;\n isExternal?: boolean;\n}\n\n/**\n * Neutral query language. Keeps any backend's native query type (Firestore's\n * `Query`, SQL, etc.) from leaking through the engine.\n *\n * Op support is backend-dependent — see each adapter. `contains` is a\n * case-insensitive substring match; `in`/`nin` take an array value.\n */\nexport type QueryFilterOp =\n | \"eq\"\n | \"ne\"\n | \"lt\"\n | \"lte\"\n | \"gt\"\n | \"gte\"\n | \"in\"\n | \"nin\"\n | \"contains\";\n\n/** A single field condition. */\nexport type QueryFilter = { field: string; op: QueryFilterOp; value: unknown };\n\n/**\n * A disjunction: the inner filters are combined with OR. Sits alongside plain\n * filters in `Query.filters`, which are combined with AND at the top level.\n */\nexport type QueryFilterGroup = { or: QueryFilter[] };\n\n/** Either a bare condition (AND-ed) or an OR group. */\nexport type QueryCondition = QueryFilter | QueryFilterGroup;\n\nexport type Query = {\n /** Top-level conditions combined with AND. Use `{ or: [...] }` for disjunction. */\n filters?: QueryCondition[];\n orderBy?: { field: string; direction: \"asc\" | \"desc\" }[];\n limit?: number;\n /** Skip this many rows (offset pagination). */\n offset?: number;\n /**\n * Fields holding references to other documents to inline-resolve after the\n * primary fetch. Resolved by {@link resolveRelations}, never by the adapter.\n */\n populate?: string[];\n};\n\n/** Narrow a {@link QueryCondition} to an OR group. */\nexport function isFilterGroup(c: QueryCondition): c is QueryFilterGroup {\n return typeof c === \"object\" && c !== null && \"or\" in c;\n}\n\n/**\n * A reference to another document. Either self-describing (`{ collection, id }`)\n * or a bare id string resolved via a {@link RelationConfig}.\n */\nexport type Ref = { collection: string; id: string };\n\n/**\n * Maps a reference field name → the collection it points to. Needed only when\n * refs are stored as bare id strings (self-describing `Ref` objects don't need\n * it). Used by {@link resolveRelations}.\n */\nexport type RelationConfig = Record<string, { collection: string }>;\n\n/**\n * Persistence contract. Every backend (Firestore, Postgres, …) implements this;\n * the engine and the route factory only ever speak to this interface.\n */\nexport interface DataAdapter {\n fetchCollection<T = Record<string, any>>(\n collection: string,\n q?: Query,\n ): Promise<(T & { id: string })[]>;\n fetchById<T = Record<string, any>>(\n collection: string,\n id: string,\n ): Promise<(T & { id: string }) | null>;\n create<T = Record<string, any>>(\n collection: string,\n data: T,\n ): Promise<T & { id: string }>;\n createWithId<T = Record<string, any>>(\n collection: string,\n id: string,\n data: T,\n ): Promise<T & { id: string }>;\n update<T = Record<string, any>>(\n collection: string,\n id: string,\n data: Partial<T>,\n ): Promise<void>;\n upsert<T = Record<string, any>>(\n collection: string,\n id: string,\n data: Partial<T>,\n ): Promise<void>;\n delete(collection: string, id: string): Promise<void>;\n}\n\n/**\n * The identity resolved from an incoming request by an {@link AuthAdapter}.\n * Minimal by design: only `isAdmin` is meaningful to the default gate. Carry any\n * additional claims (roles, scopes, tenant, …) as extra keys and authorize on\n * them with a custom `authorize` predicate. `userId`/`email` are conventional\n * but optional.\n */\nexport interface AuthIdentity {\n isAdmin: boolean;\n userId?: string;\n email?: string;\n [claim: string]: unknown;\n}\n\n/**\n * Decides whether a resolved identity may perform admin actions. Defaults to\n * `identity.isAdmin === true`; override to gate on roles/scopes/etc.\n */\nexport type AuthorizeFn = (\n identity: AuthIdentity,\n req: Request,\n) => boolean | Promise<boolean>;\n\n/**\n * Server-side auth contract. Gates every admin API route. Return `null` to\n * reject outright; otherwise the gate's `authorize` predicate decides.\n */\nexport interface AuthAdapter {\n verifyRequest(req: Request): Promise<AuthIdentity | null>;\n}\n\n/**\n * Client half of storage: performs the browser-side upload and returns the\n * final URL. Has zero server dependencies, so it is safe to import in client\n * components. Built from e.g. `@dalgoridim/headless-cms/storage/cloudinary`.\n */\nexport interface ClientStorageAdapter {\n upload(file: File): Promise<{ url: string }>;\n}\n\n/**\n * Server half of storage: issues a presign / signature (or, for local storage,\n * writes the file). Mounted by the route factory at `${apiBasePath}/sign`.\n * Pulls in server-only SDKs, so it lives in a `/server` subpath. Built from e.g.\n * `@dalgoridim/headless-cms/storage/cloudinary/server`.\n */\nexport interface ServerStorageAdapter {\n sign(req: Request): Promise<unknown>;\n}\n\n/** Full two-sided contract (rarely needed; client and server halves are split). */\nexport interface StorageAdapter extends ClientStorageAdapter {\n sign?(req: Request): Promise<unknown>;\n}\n\n/**\n * Minimal client-side auth state the edit primitives depend on. Any auth\n * implementation (Firebase, NextAuth, custom) provides this via a context;\n * `@dalgoridim/headless-cms/auth/firebase/client` ships the default.\n */\nexport interface CmsAuthState {\n isAdmin: boolean;\n isEditing: boolean;\n toggleEdit: () => void;\n}\n"],"mappings":"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","names":["rows"]}
1
+ {"version":3,"sources":["../../../src/adapters/postgres/index.ts","../../../src/types.ts"],"sourcesContent":["import { Pool } from \"pg\";\nimport { randomUUID } from \"node:crypto\";\nimport type {\n DataAdapter,\n Query,\n QueryCondition,\n QueryFilter,\n QueryFilterOp,\n} from \"../../types\";\nimport { isFilterGroup } from \"../../types\";\n\n/** Column type hints for a registered (typed) collection. */\nexport type ColumnType = \"text\" | \"int\" | \"float\" | \"bool\" | \"date\" | \"timestamptz\" | \"jsonb\";\n\nexport interface CollectionConfig {\n /** Physical table name. */\n table: string;\n /** Map of section field → SQL column type. Unlisted fields fall into `extra`. */\n columns: Record<string, ColumnType>;\n}\n\nexport interface PostgresAdapterConfig {\n /** Provide a connection string… */\n connectionString?: string;\n /** …or an existing pg Pool. */\n pool?: Pool;\n /**\n * Registry of typed collections. Anything not registered here is stored in\n * the shared JSONB `documents` table.\n */\n collections?: Record<string, CollectionConfig>;\n /** Name of the fallback JSONB table. Default `documents`. */\n documentsTable?: string;\n}\n\n/** Binary ops that render as `col <op> $n`. */\nconst BINARY_OP: Partial<Record<QueryFilterOp, string>> = {\n eq: \"=\",\n ne: \"<>\",\n lt: \"<\",\n lte: \"<=\",\n gt: \">\",\n gte: \">=\",\n};\n\nconst SQL_TYPE: Record<ColumnType, string> = {\n text: \"text\",\n int: \"integer\",\n float: \"double precision\",\n bool: \"boolean\",\n date: \"date\",\n timestamptz: \"timestamptz\",\n jsonb: \"jsonb\",\n};\n\n/**\n * Hybrid Postgres adapter. Unregistered collections live in a shared JSONB\n * `documents` table; registered collections map flat section fields onto typed\n * columns, with a JSONB `extra` column so unmapped fields are never dropped.\n */\nexport class PostgresDataAdapter implements DataAdapter {\n private readonly pool: Pool;\n private readonly collections: Record<string, CollectionConfig>;\n private readonly documentsTable: string;\n\n constructor(config: PostgresAdapterConfig = {}) {\n if (config.pool) {\n this.pool = config.pool;\n } else if (config.connectionString) {\n this.pool = new Pool({ connectionString: config.connectionString });\n } else {\n throw new Error(\n \"PostgresDataAdapter requires either `pool` or `connectionString`.\",\n );\n }\n this.collections = config.collections ?? {};\n this.documentsTable = config.documentsTable ?? \"documents\";\n }\n\n private cfg(collection: string): CollectionConfig | undefined {\n return this.collections[collection];\n }\n\n /** Create the documents table and every registered typed table if missing. */\n async migrate(): Promise<void> {\n await this.pool.query(`\n CREATE TABLE IF NOT EXISTS ${ident(this.documentsTable)} (\n id text NOT NULL,\n collection text NOT NULL,\n data jsonb NOT NULL,\n created_at timestamptz DEFAULT now(),\n updated_at timestamptz DEFAULT now(),\n PRIMARY KEY (collection, id)\n );\n `);\n await this.pool.query(\n `CREATE INDEX IF NOT EXISTS ${ident(this.documentsTable + \"_collection_created_idx\")}\n ON ${ident(this.documentsTable)} (collection, created_at DESC);`,\n );\n\n for (const cfg of Object.values(this.collections)) {\n const cols = Object.entries(cfg.columns)\n .map(([name, type]) => `${ident(name)} ${SQL_TYPE[type]}`)\n .join(\",\\n \");\n await this.pool.query(`\n CREATE TABLE IF NOT EXISTS ${ident(cfg.table)} (\n id text PRIMARY KEY,\n ${cols ? cols + \",\" : \"\"}\n extra jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz DEFAULT now(),\n updated_at timestamptz DEFAULT now()\n );\n `);\n }\n }\n\n // --- row <-> section mapping ------------------------------------------------\n\n /** Split a section into typed columns + leftover `extra`. */\n private toTypedRow(cfg: CollectionConfig, data: Record<string, unknown>) {\n const known: Record<string, unknown> = {};\n const extra: Record<string, unknown> = {};\n for (const [key, value] of Object.entries(data)) {\n if (key === \"id\" || key === \"collection\") continue;\n if (key in cfg.columns) known[key] = value;\n else extra[key] = value;\n }\n return { known, extra };\n }\n\n private fromTypedRow(\n collection: string,\n cfg: CollectionConfig,\n row: Record<string, unknown>,\n ) {\n const { id, extra, created_at, updated_at, ...rest } = row;\n void created_at;\n void updated_at;\n return {\n id: id as string,\n collection,\n ...(rest as Record<string, unknown>),\n ...((extra as Record<string, unknown>) ?? {}),\n };\n }\n\n // --- reads -----------------------------------------------------------------\n\n async fetchById<T = Record<string, unknown>>(\n collection: string,\n id: string,\n ): Promise<(T & { id: string }) | null> {\n const cfg = this.cfg(collection);\n if (cfg) {\n const { rows } = await this.pool.query(\n `SELECT * FROM ${ident(cfg.table)} WHERE id = $1`,\n [id],\n );\n if (!rows[0]) return null;\n return this.fromTypedRow(collection, cfg, rows[0]) as unknown as T & {\n id: string;\n };\n }\n const { rows } = await this.pool.query(\n `SELECT id, data FROM ${ident(this.documentsTable)} WHERE id = $1 AND collection = $2`,\n [id, collection],\n );\n if (!rows[0]) return null;\n return { id: rows[0].id, collection, ...rows[0].data } as unknown as T & {\n id: string;\n };\n }\n\n async fetchCollection<T = Record<string, unknown>>(\n collection: string,\n q?: Query,\n ): Promise<(T & { id: string })[]> {\n const cfg = this.cfg(collection);\n const params: unknown[] = [];\n const where: string[] = [];\n\n const colExpr = (field: string): string => {\n if (cfg) {\n return field in cfg.columns ? ident(field) : `extra->>${literal(field)}`;\n }\n return `data->>${literal(field)}`;\n };\n\n /** Render one field condition to SQL, pushing its bound params. */\n const renderFilter = (f: QueryFilter): string => {\n const col = colExpr(f.field);\n if (f.op === \"in\" || f.op === \"nin\") {\n params.push(f.value);\n const cmp = f.op === \"in\" ? \"= ANY\" : \"<> ALL\";\n return `${col} ${cmp}($${params.length})`;\n }\n if (f.op === \"contains\") {\n params.push(`%${String(f.value)}%`);\n return `${col} ILIKE $${params.length}`;\n }\n const op = BINARY_OP[f.op];\n if (!op) throw new Error(`Unsupported query op: ${f.op}`);\n params.push(f.value);\n return `${col} ${op} $${params.length}`;\n };\n\n /** Render an AND-level condition, expanding OR groups into `(a OR b)`. */\n const renderCondition = (c: QueryCondition): string => {\n if (isFilterGroup(c)) {\n if (!c.or.length) return \"TRUE\";\n return `(${c.or.map(renderFilter).join(\" OR \")})`;\n }\n return renderFilter(c);\n };\n\n if (!cfg) {\n params.push(collection);\n where.push(`collection = $${params.length}`);\n }\n\n for (const c of q?.filters ?? []) {\n where.push(renderCondition(c));\n }\n\n const orderBy =\n (q?.orderBy ?? []).map((o) => `${colExpr(o.field)} ${o.direction === \"asc\" ? \"ASC\" : \"DESC\"}`).join(\", \") ||\n \"created_at DESC\";\n\n const limit = q?.limit != null ? ` LIMIT ${Number(q.limit)}` : \"\";\n const offset = q?.offset != null ? ` OFFSET ${Number(q.offset)}` : \"\";\n const whereSql = where.length ? ` WHERE ${where.join(\" AND \")}` : \"\";\n const table = cfg ? cfg.table : this.documentsTable;\n const select = cfg ? \"*\" : \"id, data\";\n\n const { rows } = await this.pool.query(\n `SELECT ${select} FROM ${ident(table)}${whereSql} ORDER BY ${orderBy}${limit}${offset}`,\n params,\n );\n\n if (cfg) {\n return rows.map((r) => this.fromTypedRow(collection, cfg, r)) as unknown as (T & {\n id: string;\n })[];\n }\n return rows.map((r) => ({ id: r.id, collection, ...r.data })) as unknown as (T & {\n id: string;\n })[];\n }\n\n // --- writes ----------------------------------------------------------------\n\n async create<T = Record<string, unknown>>(\n collection: string,\n data: T,\n ): Promise<T & { id: string }> {\n return this.createWithId(collection, randomUUID(), data);\n }\n\n async createWithId<T = Record<string, unknown>>(\n collection: string,\n id: string,\n data: T,\n ): Promise<T & { id: string }> {\n const cfg = this.cfg(collection);\n const record = data as Record<string, unknown>;\n\n if (cfg) {\n const { known, extra } = this.toTypedRow(cfg, record);\n const cols = [\"id\", ...Object.keys(known), \"extra\"];\n const vals = [id, ...Object.values(known), JSON.stringify(extra)];\n const placeholders = cols.map((_, i) => `$${i + 1}`).join(\", \");\n await this.pool.query(\n `INSERT INTO ${ident(cfg.table)} (${cols.map(ident).join(\", \")})\n VALUES (${placeholders})`,\n vals,\n );\n } else {\n await this.pool.query(\n `INSERT INTO ${ident(this.documentsTable)} (id, collection, data)\n VALUES ($1, $2, $3)`,\n [id, collection, JSON.stringify(record)],\n );\n }\n return { id, ...(data as T) };\n }\n\n async update<T = Record<string, unknown>>(\n collection: string,\n id: string,\n data: Partial<T>,\n ): Promise<void> {\n await this.write(collection, id, data, \"update\");\n }\n\n async upsert<T = Record<string, unknown>>(\n collection: string,\n id: string,\n data: Partial<T>,\n ): Promise<void> {\n await this.write(collection, id, data, \"upsert\");\n }\n\n private async write(\n collection: string,\n id: string,\n data: Record<string, unknown>,\n mode: \"update\" | \"upsert\",\n ): Promise<void> {\n const cfg = this.cfg(collection);\n\n if (cfg) {\n const { known, extra } = this.toTypedRow(cfg, data);\n const setCols = Object.keys(known);\n const setExpr = setCols\n .map((c, i) => `${ident(c)} = $${i + 2}`)\n .join(\", \");\n // Merge into extra so unmapped fields aren't dropped.\n const extraIdx = setCols.length + 2;\n const sets = [\n setExpr,\n // Table-qualified: in ON CONFLICT DO UPDATE both the target table and the\n // `excluded` pseudo-relation expose `extra`, so a bare ref is ambiguous.\n `extra = ${ident(cfg.table)}.${ident(\"extra\")} || $${extraIdx}::jsonb`,\n `updated_at = now()`,\n ]\n .filter(Boolean)\n .join(\", \");\n const params = [id, ...Object.values(known), JSON.stringify(extra)];\n\n if (mode === \"upsert\") {\n const insertCols = [\"id\", ...setCols, \"extra\"];\n const insertVals = params;\n const placeholders = insertCols.map((_, i) => `$${i + 1}`).join(\", \");\n await this.pool.query(\n `INSERT INTO ${ident(cfg.table)} (${insertCols.map(ident).join(\", \")})\n VALUES (${placeholders})\n ON CONFLICT (id) DO UPDATE SET ${sets}`,\n insertVals,\n );\n } else {\n await this.pool.query(\n `UPDATE ${ident(cfg.table)} SET ${sets} WHERE id = $1`,\n params,\n );\n }\n return;\n }\n\n // JSONB documents table: shallow-merge the patch into `data`.\n if (mode === \"upsert\") {\n await this.pool.query(\n `INSERT INTO ${ident(this.documentsTable)} (id, collection, data)\n VALUES ($1, $2, $3)\n ON CONFLICT (collection, id) DO UPDATE\n SET data = ${ident(this.documentsTable)}.data || $3::jsonb,\n updated_at = now()`,\n [id, collection, JSON.stringify(data)],\n );\n } else {\n await this.pool.query(\n `UPDATE ${ident(this.documentsTable)}\n SET data = data || $2::jsonb, updated_at = now()\n WHERE id = $1 AND collection = $3`,\n [id, JSON.stringify(data), collection],\n );\n }\n }\n\n async delete(collection: string, id: string): Promise<void> {\n const cfg = this.cfg(collection);\n if (cfg) {\n await this.pool.query(`DELETE FROM ${ident(cfg.table)} WHERE id = $1`, [id]);\n } else {\n await this.pool.query(\n `DELETE FROM ${ident(this.documentsTable)} WHERE id = $1 AND collection = $2`,\n [id, collection],\n );\n }\n }\n}\n\n/** Quote a SQL identifier safely. */\nfunction ident(name: string): string {\n return `\"${name.replace(/\"/g, '\"\"')}\"`;\n}\n\n/** Quote a SQL string literal (used for static jsonb keys, never user values). */\nfunction literal(value: string): string {\n return `'${value.replace(/'/g, \"''\")}'`;\n}\n","/**\n * Shared, runtime-free types. Safe to import in any environment (client or\n * server) — this module pulls in zero runtime dependencies.\n */\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\n\n/**\n * Engine addressing fields. The engine needs to know an entity's `id` and which\n * `collection` it lives in to route saves; everything else is the consumer's own\n * shape. Kept separate from the user's content type `T` so we never force these\n * onto domain models — use {@link Editable}<YourType> when you want both.\n */\nexport interface EntityAddress {\n id: string;\n collection: string;\n}\n\n/**\n * The user's content type `T` decorated with the engine's addressing fields.\n * `T` is completely unconstrained — bring any shape; the engine only adds `id`\n * and `collection`.\n */\nexport type Editable<T = Record<string, any>> = T & EntityAddress;\n\n/**\n * A single editable record. Schemaless by design. Equivalent to\n * `Editable<Record<string, any>>`; kept as a named alias for the common case and\n * for backwards compatibility.\n */\nexport type Section = Editable;\n\nexport type SectionMap = Record<string, Section>;\n\n/**\n * The shape the engine holds in memory:\n * `{ [collection]: { [sectionKey]: Section } }`.\n */\nexport type NestedSections = {\n [collection: string]: {\n [sectionKey: string]: Section;\n };\n};\n\n/**\n * A single item in an editable collection list (e.g. a project or a tool). Just\n * a record with a stable `id`. Used by the client provider's collection ops\n * (create / delete / reorder) which manage *which* items exist and their order,\n * complementing {@link Section}s which manage a single item's editable fields.\n */\nexport type CollectionItem = Record<string, unknown> & { id: string };\n\n/**\n * An image edit queued in the provider. The actual upload is deferred until\n * save. `file` is null when the user pasted an external URL (`isExternal`).\n */\nexport interface PendingImage {\n file: File | null;\n localUrl: string;\n sectionKey: string;\n fieldKey: string;\n collection: string;\n docId: string;\n isExternal?: boolean;\n}\n\n/**\n * Neutral query language. Keeps any backend's native query type (Firestore's\n * `Query`, SQL, etc.) from leaking through the engine.\n *\n * Op support is backend-dependent — see each adapter. `contains` is a\n * case-insensitive substring match; `in`/`nin` take an array value.\n */\nexport type QueryFilterOp =\n | \"eq\"\n | \"ne\"\n | \"lt\"\n | \"lte\"\n | \"gt\"\n | \"gte\"\n | \"in\"\n | \"nin\"\n | \"contains\";\n\n/** A single field condition. */\nexport type QueryFilter = { field: string; op: QueryFilterOp; value: unknown };\n\n/**\n * A disjunction: the inner filters are combined with OR. Sits alongside plain\n * filters in `Query.filters`, which are combined with AND at the top level.\n */\nexport type QueryFilterGroup = { or: QueryFilter[] };\n\n/** Either a bare condition (AND-ed) or an OR group. */\nexport type QueryCondition = QueryFilter | QueryFilterGroup;\n\nexport type Query = {\n /** Top-level conditions combined with AND. Use `{ or: [...] }` for disjunction. */\n filters?: QueryCondition[];\n orderBy?: { field: string; direction: \"asc\" | \"desc\" }[];\n limit?: number;\n /** Skip this many rows (offset pagination). */\n offset?: number;\n /**\n * Fields holding references to other documents to inline-resolve after the\n * primary fetch. Resolved by {@link resolveRelations}, never by the adapter.\n */\n populate?: string[];\n};\n\n/** Narrow a {@link QueryCondition} to an OR group. */\nexport function isFilterGroup(c: QueryCondition): c is QueryFilterGroup {\n return typeof c === \"object\" && c !== null && \"or\" in c;\n}\n\n/**\n * A reference to another document. Either self-describing (`{ collection, id }`)\n * or a bare id string resolved via a {@link RelationConfig}.\n */\nexport type Ref = { collection: string; id: string };\n\n/**\n * Maps a reference field name → the collection it points to. Needed only when\n * refs are stored as bare id strings (self-describing `Ref` objects don't need\n * it). Used by {@link resolveRelations}.\n */\nexport type RelationConfig = Record<string, { collection: string }>;\n\n/**\n * Persistence contract. Every backend (Firestore, Postgres, …) implements this;\n * the engine and the route factory only ever speak to this interface.\n */\nexport interface DataAdapter {\n fetchCollection<T = Record<string, any>>(\n collection: string,\n q?: Query,\n ): Promise<(T & { id: string })[]>;\n fetchById<T = Record<string, any>>(\n collection: string,\n id: string,\n ): Promise<(T & { id: string }) | null>;\n create<T = Record<string, any>>(\n collection: string,\n data: T,\n ): Promise<T & { id: string }>;\n createWithId<T = Record<string, any>>(\n collection: string,\n id: string,\n data: T,\n ): Promise<T & { id: string }>;\n update<T = Record<string, any>>(\n collection: string,\n id: string,\n data: Partial<T>,\n ): Promise<void>;\n upsert<T = Record<string, any>>(\n collection: string,\n id: string,\n data: Partial<T>,\n ): Promise<void>;\n delete(collection: string, id: string): Promise<void>;\n}\n\n/**\n * The identity resolved from an incoming request by an {@link AuthAdapter}.\n * Minimal by design: only `isAdmin` is meaningful to the default gate. Carry any\n * additional claims (roles, scopes, tenant, …) as extra keys and authorize on\n * them with a custom `authorize` predicate. `userId`/`email` are conventional\n * but optional.\n */\nexport interface AuthIdentity {\n isAdmin: boolean;\n userId?: string;\n email?: string;\n [claim: string]: unknown;\n}\n\n/**\n * Decides whether a resolved identity may perform admin actions. Defaults to\n * `identity.isAdmin === true`; override to gate on roles/scopes/etc.\n */\nexport type AuthorizeFn = (\n identity: AuthIdentity,\n req: Request,\n) => boolean | Promise<boolean>;\n\n/**\n * Server-side auth contract. Gates every admin API route. Return `null` to\n * reject outright; otherwise the gate's `authorize` predicate decides.\n */\nexport interface AuthAdapter {\n verifyRequest(req: Request): Promise<AuthIdentity | null>;\n}\n\n/**\n * Client half of storage: performs the browser-side upload and returns the\n * final URL. Has zero server dependencies, so it is safe to import in client\n * components. Built from e.g. `@dalgoridim/headless-cms/storage/cloudinary`.\n */\nexport interface ClientStorageAdapter {\n upload(file: File): Promise<{ url: string }>;\n}\n\n/**\n * Server half of storage: issues a presign / signature (or, for local storage,\n * writes the file). Mounted by the route factory at `${apiBasePath}/sign`.\n * Pulls in server-only SDKs, so it lives in a `/server` subpath. Built from e.g.\n * `@dalgoridim/headless-cms/storage/cloudinary/server`.\n */\nexport interface ServerStorageAdapter {\n sign(req: Request): Promise<unknown>;\n}\n\n/** Full two-sided contract (rarely needed; client and server halves are split). */\nexport interface StorageAdapter extends ClientStorageAdapter {\n sign?(req: Request): Promise<unknown>;\n}\n\n/**\n * Minimal client-side auth state the edit primitives depend on. Any auth\n * implementation (Firebase, NextAuth, custom) provides this via a context;\n * `@dalgoridim/headless-cms/auth/firebase/client` ships the default.\n */\nexport interface CmsAuthState {\n isAdmin: boolean;\n isEditing: boolean;\n toggleEdit: () => void;\n}\n"],"mappings":"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","names":["rows"]}
@@ -61,12 +61,14 @@ var dirtyKey = (collection, sectionKey) => `${collection}:${sectionKey}`;
61
61
  var PageProvider = ({
62
62
  children,
63
63
  initialSections = {},
64
+ initialCollections = {},
64
65
  apiBasePath = "/api/admin",
65
66
  storage,
66
67
  notify = defaultNotifier
67
68
  }) => {
68
69
  const [saving, setSaving] = (0, import_react.useState)(false);
69
70
  const [sections, setSections] = (0, import_react.useState)(initialSections);
71
+ const [collections, setCollections] = (0, import_react.useState)(initialCollections);
70
72
  const [pendingImages, setPendingImages] = (0, import_react.useState)([]);
71
73
  const [dirtySections, setDirtySections] = (0, import_react.useState)(/* @__PURE__ */ new Set());
72
74
  const hasUnsavedChanges = dirtySections.size > 0;
@@ -256,6 +258,98 @@ var PageProvider = ({
256
258
  setSaving(false);
257
259
  }
258
260
  }, [sections, pendingImages, dirtySections, saving, resolveImageUrl, persist, notify]);
261
+ const createItem = (0, import_react.useCallback)(
262
+ async (collection, data, opts) => {
263
+ var _a, _b, _c, _d;
264
+ const id = (_d = (_c = opts == null ? void 0 : opts.id) != null ? _c : (_b = (_a = globalThis.crypto) == null ? void 0 : _a.randomUUID) == null ? void 0 : _b.call(_a)) != null ? _d : `${collection}-${Date.now()}`;
265
+ const item = __spreadValues({ id }, data);
266
+ setCollections((prev) => {
267
+ var _a2;
268
+ const list = (_a2 = prev[collection]) != null ? _a2 : [];
269
+ return __spreadProps(__spreadValues({}, prev), {
270
+ [collection]: (opts == null ? void 0 : opts.atStart) ? [item, ...list] : [...list, item]
271
+ });
272
+ });
273
+ try {
274
+ const res = await fetch(`${apiBasePath}/${collection}/${id}`, {
275
+ method: "PUT",
276
+ headers: { "Content-Type": "application/json" },
277
+ body: JSON.stringify(data)
278
+ });
279
+ if (!res.ok) throw new Error("Failed to create item");
280
+ notify.success("Item added");
281
+ return id;
282
+ } catch (error) {
283
+ setCollections((prev) => {
284
+ var _a2;
285
+ return __spreadProps(__spreadValues({}, prev), {
286
+ [collection]: ((_a2 = prev[collection]) != null ? _a2 : []).filter((it) => it.id !== id)
287
+ });
288
+ });
289
+ notify.error("Failed to add item");
290
+ throw error;
291
+ }
292
+ },
293
+ [apiBasePath, notify]
294
+ );
295
+ const deleteItem = (0, import_react.useCallback)(
296
+ async (collection, id) => {
297
+ let removed = [];
298
+ setCollections((prev) => {
299
+ var _a;
300
+ removed = (_a = prev[collection]) != null ? _a : [];
301
+ return __spreadProps(__spreadValues({}, prev), {
302
+ [collection]: removed.filter((it) => it.id !== id)
303
+ });
304
+ });
305
+ try {
306
+ const res = await fetch(`${apiBasePath}/${collection}/${id}`, {
307
+ method: "DELETE"
308
+ });
309
+ if (!res.ok) throw new Error("Failed to delete item");
310
+ notify.success("Item removed");
311
+ } catch (error) {
312
+ setCollections((prev) => __spreadProps(__spreadValues({}, prev), { [collection]: removed }));
313
+ notify.error("Failed to remove item");
314
+ throw error;
315
+ }
316
+ },
317
+ [apiBasePath, notify]
318
+ );
319
+ const reorderItems = (0, import_react.useCallback)(
320
+ async (collection, orderedIds) => {
321
+ let previous = [];
322
+ let next = [];
323
+ setCollections((prev) => {
324
+ var _a;
325
+ previous = (_a = prev[collection]) != null ? _a : [];
326
+ const byId = new Map(previous.map((it) => [it.id, it]));
327
+ next = orderedIds.flatMap((id, index) => {
328
+ const item = byId.get(id);
329
+ return item ? [__spreadProps(__spreadValues({}, item), { order: index })] : [];
330
+ });
331
+ return __spreadProps(__spreadValues({}, prev), { [collection]: next });
332
+ });
333
+ try {
334
+ await Promise.all(
335
+ next.map(async (it) => {
336
+ const res = await fetch(`${apiBasePath}/${collection}/${it.id}`, {
337
+ method: "PATCH",
338
+ headers: { "Content-Type": "application/json" },
339
+ body: JSON.stringify({ order: it.order })
340
+ });
341
+ if (!res.ok) throw new Error("Failed to reorder");
342
+ })
343
+ );
344
+ notify.success("Order updated");
345
+ } catch (error) {
346
+ setCollections((prev) => __spreadProps(__spreadValues({}, prev), { [collection]: previous }));
347
+ notify.error("Failed to update order");
348
+ throw error;
349
+ }
350
+ },
351
+ [apiBasePath, notify]
352
+ );
259
353
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
260
354
  PageContext.Provider,
261
355
  {
@@ -268,7 +362,11 @@ var PageProvider = ({
268
362
  editField,
269
363
  setPendingImage,
270
364
  saveSection,
271
- saveAll
365
+ saveAll,
366
+ collections,
367
+ createItem,
368
+ deleteItem,
369
+ reorderItems
272
370
  },
273
371
  children
274
372
  }