@dalgoridim/headless-cms 0.1.0 → 0.2.1

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.
@@ -0,0 +1,125 @@
1
+ # Architecture & design notes
2
+
3
+ Core/internal documentation for `@dalgoridim/headless-cms`. For consumer usage,
4
+ see the [README](./README.md).
5
+
6
+ ## Philosophy: harness, not framework
7
+
8
+ The package wires **behavior** and imposes nothing else. Concretely:
9
+
10
+ - **Zero runtime dependencies.** No CSS framework, icon set, toast library, or
11
+ markdown renderer is bundled. Anything visual or opinionated is the consumer's.
12
+ - **The UI primitives are headless.** They manage `contentEditable`, uploads,
13
+ saves, and edit state, and expose that state (via `data-*` attributes,
14
+ render-props, and hooks) so the consumer owns 100% of the markup.
15
+ - **The store is schemaless.** The engine only ever adds an `id` and `collection`
16
+ for addressing; your content type `T` is never forced to declare them
17
+ (`Editable<T> = T & { id; collection }`).
18
+
19
+ This is a deliberate reaction to the original extraction, where the components
20
+ shipped a finished, portfolio-specific look (Tailwind + a dark palette + a
21
+ `primary` token + a bespoke markup syntax). Adopting the package then meant
22
+ inheriting that design. The redesign split **behavior (package)** from
23
+ **appearance (consumer)**.
24
+
25
+ ## Two layers
26
+
27
+ | Layer | Responsibility | Freedom |
28
+ |---|---|---|
29
+ | **Data / server** | CRUD, the neutral `Query`, auth gating, storage signing | Schemaless; adapters are generic over `T` |
30
+ | **UI / client** | inline-edit behavior, save buffering, upload flow | Headless; consumer supplies all styling/markup |
31
+
32
+ ## Subpath exports & the client/server bundle boundary
33
+
34
+ Server-only SDKs must never reach the client bundle. Two mechanisms enforce this:
35
+
36
+ 1. **Split entries per concern.** Each storage adapter is two modules:
37
+ - `storage/<x>` — the **client** half (`upload`), pure `fetch`, safe in client
38
+ components.
39
+ - `storage/<x>/server` — the **server** half (`sign`), pulls in the SDK.
40
+
41
+ This split exists because a single module that *also* contained a lazy
42
+ `import("cloudinary")` still got traced into the client bundle by
43
+ Next/Turbopack → `Can't resolve 'fs'`. Splitting the entry removes the trace
44
+ entirely.
45
+
46
+ 2. **Auth/data adapters live behind `/server`, `/adapters/*`, `/auth/*`** so a
47
+ client import can never transitively pull a Node-only SDK.
48
+
49
+ ## One React context across entries
50
+
51
+ The built-in auth providers (e.g. `auth/firebase/client`) import
52
+ `CmsAuthProvider` through the package's **public** `@dalgoridim/headless-cms/client`
53
+ specifier — not a relative path — so the provider and the consumer's primitives
54
+ share the **same** context instance at runtime. This is enforced with a tsup
55
+ `external` regex (`/^@dalgoridim\/headless-cms(\/.*)?$/`) plus `tsconfig` paths.
56
+ Using a relative import here would create a second context instance and silently
57
+ break `useCmsAuth`.
58
+
59
+ ## The neutral Query & backend parity
60
+
61
+ `Query` is intentionally backend-agnostic so no native query type (Firestore's
62
+ `Query`, raw SQL) leaks through the engine. Not every backend can honor every
63
+ operator; rather than return wrong results, an adapter **throws** on what it
64
+ cannot do.
65
+
66
+ | Capability | Postgres | Firestore |
67
+ |---|:--:|:--:|
68
+ | `eq` `ne` `lt` `lte` `gt` `gte` | ✅ | ✅ |
69
+ | `in` / `nin` | ✅ | ✅ |
70
+ | `contains` (case-insensitive substring) | ✅ (`ILIKE`) | ❌ throws (no native substring) |
71
+ | `{ or: [...] }` groups | ✅ | ❌ throws |
72
+ | `offset` pagination | ✅ | ✅ |
73
+
74
+ ## Hybrid Postgres adapter
75
+
76
+ Two storage strategies under one adapter:
77
+
78
+ - **Unregistered collections** → a shared JSONB `documents` table, keyed by a
79
+ composite `PRIMARY KEY (collection, id)` (global `id` PKs collided across
80
+ collections — e.g. `project/1`, `skill/1`). Fully schemaless, zero config.
81
+ - **Registered collections** → flat fields mapped onto typed columns, **plus** a
82
+ JSONB `extra` column so unmapped fields are never dropped.
83
+
84
+ Notable details:
85
+
86
+ - `ON CONFLICT DO UPDATE` for typed-table upsert qualifies `extra` as
87
+ `<table>.extra` — a bare `extra` is ambiguous between the target table and the
88
+ `excluded` pseudo-relation.
89
+ - **Caveat:** JSONB fields are extracted as **text** (`data->>'field'`), so
90
+ numeric ordering/comparison in the schemaless table is lexicographic
91
+ (`"10" < "5"`). Register the collection with a typed `int`/`float` column for
92
+ true numeric behavior.
93
+
94
+ ## Relations
95
+
96
+ References are plain field values — a self-describing `{ collection, id }`, a bare
97
+ id string, or an array of either. `resolveRelations(adapter, docs, opts)` runs
98
+ **engine-side** (never in an adapter) so it works identically over Postgres,
99
+ Firestore, or any custom `DataAdapter`. Loads are deduplicated (one `fetchById`
100
+ per unique `(collection, id)`) and parallelized; unresolved refs are left
101
+ untouched.
102
+
103
+ ## Auth model
104
+
105
+ `AuthIdentity` is minimal and open: only `isAdmin` is meaningful to the default
106
+ gate; carry any other claims (roles, scopes, tenant) as extra keys. The gate
107
+ authorizes via an injectable `AuthorizeFn` (`(identity, req) => boolean`),
108
+ defaulting to `identity.isAdmin === true`. This lets a consumer gate on anything
109
+ without the package prescribing an identity shape.
110
+
111
+ ## Headless UI internals
112
+
113
+ - **`ContentEditSpan`** — wires `contentEditable`, persists on blur, and emits
114
+ `data-cms-editable` / `data-cms-editing` / `data-cms-focused`. Rich-text
115
+ rendering is injected via `renderValue(raw) => ReactNode` (default: plain text).
116
+ - **`EditableImage`** — owns the file input, object-URL previews, external-URL
117
+ validation, and the pending-upload queue; hands all state to a render-prop.
118
+ - **`useMarkdownEditor`** — value state + a selection-aware `insert` command +
119
+ save/reset, with no UI. (The reference app skins it with `@uiw/react-md-editor`.)
120
+
121
+ ## Build
122
+
123
+ `tsup` produces ESM + CJS + `.d.ts` per subpath entry, with every backend SDK and
124
+ the package's own specifier marked `external`. `files: ["dist"]` (plus the
125
+ markdown docs) controls what ships to npm.
package/README.md CHANGED
@@ -1,24 +1,35 @@
1
1
  # @dalgoridim/headless-cms
2
2
 
3
- Database-agnostic, inline-edit headless CMS engine for React / Next.js apps.
3
+ Database-agnostic, **inline-edit** headless CMS engine for React / Next.js apps.
4
4
 
5
- Content lives in your database and is editable **inline on the live site** by an
6
- authenticated admin. The persistence, auth, and storage layers are fully
7
- pluggable — swap Firestore for Postgres, Cloudinary for S3, Firebase auth for
8
- NextAuth, without touching the editing UI.
5
+ Content lives in *your* database and is editable **inline on the live site** by an
6
+ authenticated admin. Persistence, auth, and storage are fully pluggable — swap
7
+ Firestore for Postgres, Cloudinary for S3, Firebase auth for NextAuth, without
8
+ touching your UI.
9
+
10
+ **Harness, not framework.** Zero runtime dependencies; the editing components are
11
+ headless (you own all markup and styling); your content shape stays unconstrained.
12
+ You build a thin skin over the primitives (see [Styling](#styling-bring-your-own-look)) —
13
+ the package never pushes a look onto your site. The design rationale and internals
14
+ live in [ARCHITECTURE.md](./ARCHITECTURE.md).
15
+
16
+ **Live example:** [dalgoridim.com](https://dalgoridim.com) runs on this package.
17
+ Try it: anyone can toggle edit mode and change the content inline, right on the
18
+ page — but saves are gated, so only the authenticated owner's edits actually
19
+ persist. Your changes are yours alone to play with.
9
20
 
10
21
  ## Install
11
22
 
12
23
  ```bash
13
24
  npm install @dalgoridim/headless-cms
14
- # plus the adapters you actually use, e.g.:
25
+ # plus only the adapters you actually use:
15
26
  npm install firebase firebase-admin cloudinary # Firestore + Cloudinary + Firebase
16
27
  npm install pg # Postgres
17
28
  npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner # S3
18
29
  ```
19
30
 
20
31
  `react` / `react-dom` are peer deps; every backend SDK is an **optional** peer —
21
- you only install the ones your chosen adapters need.
32
+ install only what your chosen adapters need.
22
33
 
23
34
  ## Subpath exports
24
35
 
@@ -27,31 +38,32 @@ Server-only code never leaks into the client bundle. Import from the right entry
27
38
  | Entry | Contents |
28
39
  |---|---|
29
40
  | `@dalgoridim/headless-cms` | Shared types only (safe anywhere) |
30
- | `.../client` | `PageProvider`, `usePageContext`, `ContentEditSpan`, `EditableImage`, `MarkdownEditor`, `CmsAuthProvider`, `useCmsAuth` |
31
- | `.../server` | `createCmsHandlers`, `createAdminGate` |
41
+ | `.../client` | `PageProvider`, `usePageContext`, `ContentEditSpan`, `EditableImage`, `useMarkdownEditor`, `CmsAuthProvider`, `useCmsAuth` |
42
+ | `.../server` | `createCmsHandlers`, `createAdminGate`, `resolveRelations` |
32
43
  | `.../adapters/firestore` | `FirestoreDataAdapter` |
33
44
  | `.../adapters/postgres` | `PostgresDataAdapter` (hybrid JSONB + typed tables) |
34
- | `.../storage/cloudinary` \| `.../storage/s3` \| `.../storage/local` | **Client** upload adapters (`cloudinaryStorage`, `s3Storage`, `localStorage`) — pure `fetch`, safe in client components |
35
- | `.../storage/cloudinary/server` \| `.../storage/s3/server` \| `.../storage/local/server` | **Server** signers (`cloudinarySign`, `s3Sign`, `localSign`) — pull in SDKs, server-only |
45
+ | `.../storage/{cloudinary,s3,local}` | **Client** upload adapters — pure `fetch`, safe in client components |
46
+ | `.../storage/{cloudinary,s3,local}/server` | **Server** signers — pull in SDKs, server-only |
36
47
  | `.../auth/firebase` | `firebaseAuth` (server gate) |
37
48
  | `.../auth/firebase/client` | `FirebaseAuthProvider`, `useFirebaseAuth` |
38
49
  | `.../auth/nextauth` | `nextAuthAuth`, `customAuth` |
39
50
 
40
- ## The three core interfaces
51
+ ## Core interfaces
41
52
 
42
53
  ```ts
43
54
  import type { DataAdapter, AuthAdapter, StorageAdapter } from "@dalgoridim/headless-cms";
44
55
  ```
45
56
 
46
- - **`DataAdapter`** — backend CRUD over a neutral `Query`.
47
- - **`AuthAdapter`** — `verifyRequest(req)` gates every admin route, server-side.
48
- - **Storage** is split into two halves so server SDKs never reach the client
49
- bundle: `ClientStorageAdapter` (`upload`, from `.../storage/<x>`) and
57
+ - **`DataAdapter`** — backend CRUD over a neutral [`Query`](#the-query-language).
58
+ - **`AuthAdapter`** — `verifyRequest(req)` resolves an identity; the gate decides
59
+ ([auth](#auth-the-gate-is-yours)).
60
+ - **Storage** is split in two so server SDKs never reach the client bundle:
61
+ `ClientStorageAdapter` (`upload`, from `.../storage/<x>`) and
50
62
  `ServerStorageAdapter` (`sign`, from `.../storage/<x>/server`).
51
63
 
52
- ## Wiring (Next.js App Router)
64
+ ## Quick start (Next.js App Router)
53
65
 
54
- **1. The admin CRUD route** — replaces a hand-written handler:
66
+ ### 1. Admin CRUD route
55
67
 
56
68
  ```ts
57
69
  // app/api/admin/[collection]/[id]/route.ts
@@ -71,7 +83,7 @@ export const { GET, PATCH, PUT, DELETE } = createCmsHandlers({
71
83
  });
72
84
  ```
73
85
 
74
- **2. The storage sign route** — uses the **server** signer:
86
+ ### 2. Storage sign route (server signer)
75
87
 
76
88
  ```ts
77
89
  // app/api/admin/sign/route.ts
@@ -90,16 +102,15 @@ export const POST = createCmsHandlers({
90
102
  }).sign;
91
103
  ```
92
104
 
93
- **3. Providers + editable content** (client):
105
+ ### 3. Providers (client)
94
106
 
95
107
  ```tsx
96
108
  "use client";
97
- import { PageProvider, ContentEditSpan, EditableImage } from "@dalgoridim/headless-cms/client";
109
+ import { PageProvider } from "@dalgoridim/headless-cms/client";
98
110
  import { FirebaseAuthProvider } from "@dalgoridim/headless-cms/auth/firebase/client";
99
111
  import { cloudinaryStorage } from "@dalgoridim/headless-cms/storage/cloudinary";
100
112
  import { auth, googleProvider } from "@/lib/firebase/config";
101
113
 
102
- // Client upload adapter — posts to the sign route, then to Cloudinary.
103
114
  const storage = cloudinaryStorage({ folder: "uploads", signEndpoint: "/api/admin/sign" });
104
115
 
105
116
  export function Providers({ initialSections, children }) {
@@ -111,18 +122,156 @@ export function Providers({ initialSections, children }) {
111
122
  </FirebaseAuthProvider>
112
123
  );
113
124
  }
125
+ ```
126
+
127
+ `PageProvider` saves `PATCH ${apiBasePath}/{collection}/{id}` (default `apiBasePath`
128
+ = `/api/admin`). Edit mode is driven by `useCmsAuth().isEditing`, which the auth
129
+ providers feed. Pass `notify` to surface your own toasts — the default logs to the
130
+ console so the package carries no toast dependency.
131
+
132
+ ## The editing primitives (headless)
114
133
 
115
- // Anywhere inside:
116
- <ContentEditSpan collection="portfolio" sectionKey="hero" fieldKey="title" as="h1">
134
+ ### `ContentEditSpan` — inline-editable text
135
+
136
+ Wires `contentEditable`, reads/writes the field, and exposes edit state via
137
+ `data-*` attributes. It ships **no styling and no markup language** — supply your
138
+ own via `className` and `renderValue`.
139
+
140
+ ```tsx
141
+ import { ContentEditSpan } from "@dalgoridim/headless-cms/client";
142
+
143
+ <ContentEditSpan
144
+ collection="pages"
145
+ sectionKey="hero"
146
+ fieldKey="title"
147
+ as="h1" // any element/tag; defaults to "span"
148
+ renderValue={(raw) => raw} // turn the stored string into nodes (default: plain text)
149
+ className="my-heading"
150
+ >
117
151
  Default title
118
152
  </ContentEditSpan>
119
- <EditableImage collection="portfolio" sectionKey="hero" fieldKey="image"
120
- docId="hero" src={section.image} />
121
153
  ```
122
154
 
123
- `PageProvider` saves `PATCH ${apiBasePath}/{collection}/{id}` (default
124
- `apiBasePath` = `/api/admin`). Edit mode is driven by `useCmsAuth().isEditing`;
125
- the built-in auth providers feed that context.
155
+ State is exposed as attributes so you can style with plain CSS (or Tailwind
156
+ `data-[...]` variants):
157
+
158
+ | Attribute | Present when |
159
+ |---|---|
160
+ | `data-cms-editable` | always (the primitive is mounted) |
161
+ | `data-cms-editing` | the current user is in edit mode |
162
+ | `data-cms-focused` | the field is actively being edited |
163
+
164
+ ```css
165
+ [data-cms-focused] { outline: 2px solid var(--accent); border-radius: 4px; }
166
+ [data-cms-editing]:hover { outline: 1px dashed var(--accent); cursor: text; }
167
+ ```
168
+
169
+ Want rich text? Pass a `renderValue` that parses your own syntax (markdown, a
170
+ custom mini-language, etc.) into React nodes — the package no longer dictates one.
171
+
172
+ ### `EditableImage` — upload / external-URL via render-prop
173
+
174
+ Manages file picking, object URLs, external-URL validation, and the pending-upload
175
+ queue. You render the chrome:
176
+
177
+ ```tsx
178
+ import { EditableImage } from "@dalgoridim/headless-cms/client";
179
+
180
+ <EditableImage collection="pages" sectionKey="hero" fieldKey="image"
181
+ docId="hero" src={section.image}>
182
+ {({ isEditing, saving, hasError, openFilePicker, setExternalUrl, imgProps }) => (
183
+ <div className="relative">
184
+ <img {...imgProps} alt="" />
185
+ {isEditing && (
186
+ <button onClick={openFilePicker} disabled={saving}>Replace</button>
187
+ )}
188
+ </div>
189
+ )}
190
+ </EditableImage>
191
+ ```
192
+
193
+ Omit the render-prop child and it falls back to a bare unstyled `<img>`.
194
+
195
+ ### `useMarkdownEditor` — headless markdown editing
196
+
197
+ State + a selection-aware `insert` command. Bring your own modal, toolbar, icons,
198
+ and preview (e.g. `react-markdown`):
199
+
200
+ ```tsx
201
+ import { useMarkdownEditor } from "@dalgoridim/headless-cms/client";
202
+
203
+ function Editor({ initialValue, onSave }) {
204
+ const md = useMarkdownEditor({ initialValue, onSave });
205
+ return (
206
+ <>
207
+ <button onClick={() => md.insert("**", "**", "bold")}>Bold</button>
208
+ <textarea ref={md.textareaRef} value={md.value}
209
+ onChange={(e) => md.setValue(e.target.value)} />
210
+ <span>{md.charCount} chars</span>
211
+ <button onClick={md.save}>Save</button>
212
+ </>
213
+ );
214
+ }
215
+ ```
216
+
217
+ ## The Query language
218
+
219
+ A neutral, backend-agnostic query passed to `DataAdapter.fetchCollection`:
220
+
221
+ ```ts
222
+ type Query = {
223
+ filters?: (QueryFilter | { or: QueryFilter[] })[]; // top level AND; groups OR
224
+ orderBy?: { field: string; direction: "asc" | "desc" }[];
225
+ limit?: number;
226
+ offset?: number;
227
+ populate?: string[]; // reference fields to inline-resolve (see Relations)
228
+ };
229
+ ```
230
+
231
+ **Operators** (`QueryFilter = { field, op, value }`):
232
+
233
+ | op | meaning | Postgres | Firestore |
234
+ |---|---|:--:|:--:|
235
+ | `eq` / `ne` | equals / not equals | ✅ | ✅ |
236
+ | `lt` `lte` `gt` `gte` | comparisons | ✅ | ✅ |
237
+ | `in` / `nin` | value in / not in array | ✅ | ✅ |
238
+ | `contains` | case-insensitive substring | ✅ | ❌ throws |
239
+ | `{ or: [...] }` | disjunction | ✅ | ❌ throws |
240
+
241
+ Where a backend can't honor an op, it throws a clear error rather than returning
242
+ wrong results.
243
+
244
+ ```ts
245
+ await data.fetchCollection("posts", {
246
+ filters: [
247
+ { field: "title", op: "contains", value: "hello" },
248
+ { or: [{ field: "tag", op: "eq", value: "react" },
249
+ { field: "tag", op: "eq", value: "sql" }] },
250
+ ],
251
+ orderBy: [{ field: "createdAt", direction: "desc" }],
252
+ limit: 20,
253
+ offset: 40,
254
+ });
255
+ ```
256
+
257
+ ## Relations
258
+
259
+ References are just fields — a self-describing `{ collection, id }`, a bare id
260
+ string, or an array of either. Resolve them after a fetch with `resolveRelations`
261
+ (engine-side, works over any adapter):
262
+
263
+ ```ts
264
+ import { resolveRelations } from "@dalgoridim/headless-cms/server";
265
+
266
+ const posts = await data.fetchCollection("posts");
267
+ await resolveRelations(data, posts, {
268
+ populate: ["authorId"],
269
+ relations: { authorId: { collection: "authors" } }, // needed for bare-id refs
270
+ });
271
+ // each post.authorId is now the resolved author document
272
+ ```
273
+
274
+ Loads are deduplicated and parallelized; unresolved refs are left untouched.
126
275
 
127
276
  ## Postgres (hybrid)
128
277
 
@@ -136,35 +285,80 @@ import { PostgresDataAdapter } from "@dalgoridim/headless-cms/adapters/postgres"
136
285
  const data = new PostgresDataAdapter({
137
286
  connectionString: process.env.DATABASE_URL!,
138
287
  collections: {
139
- projects: { table: "projects", columns: { title: "text", date: "date" } },
288
+ projects: { table: "projects", columns: { title: "text", views: "int", date: "date" } },
140
289
  },
141
290
  });
142
291
  await data.migrate(); // creates documents + registered tables if missing
143
292
  ```
144
293
 
145
- ## Content markup
294
+ > **Caveat:** fields in the schemaless JSONB `documents` table are extracted as
295
+ > **text**, so numeric ordering/comparison is lexicographic (`"10" < "5"`).
296
+ > Register the collection with a typed `int`/`float` column for true numeric
297
+ > behavior.
146
298
 
147
- `ContentEditSpan` supports inline marks: `**bold**`, `*italic*`, `~~strike~~`,
148
- `^^primary color^^`, `__underline__`, `~~br~~`, and `[label](https://url)`.
149
- `MarkdownEditor` provides full GitHub-flavored markdown editing.
299
+ ## Auth: the gate is yours
150
300
 
151
- ## Styling
301
+ `AuthIdentity` is intentionally minimal and open — only `isAdmin` is meaningful to
302
+ the default gate; carry any other claims and authorize on them yourself.
152
303
 
153
- The client components ship **Tailwind utility classes** (and use a `--color-primary`
154
- custom property / `text-primary` utility for accents). The consuming app is
155
- responsible for Tailwind. With Tailwind v4, point it at the package so those
156
- classes are generated, and define a primary color:
304
+ ```ts
305
+ interface AuthIdentity {
306
+ isAdmin: boolean;
307
+ userId?: string;
308
+ email?: string;
309
+ [claim: string]: unknown; // roles, scopes, tenant, …
310
+ }
311
+ ```
157
312
 
158
- ```css
159
- /* globals.css */
160
- @import "tailwindcss";
161
- @source "../node_modules/@dalgoridim/headless-cms/dist";
313
+ Pass an `authorize` predicate to gate on whatever you like (defaults to
314
+ `identity.isAdmin === true`):
162
315
 
163
- @theme inline {
164
- --color-primary: var(--primary);
165
- }
316
+ ```ts
317
+ createCmsHandlers({
318
+ data,
319
+ auth,
320
+ authorize: (identity) => identity.role === "editor" || identity.isAdmin,
321
+ });
322
+ ```
323
+
324
+ ## Your content types stay unconstrained
325
+
326
+ The engine only adds addressing fields; your domain type `T` is never forced to
327
+ declare them:
328
+
329
+ ```ts
330
+ import type { Editable } from "@dalgoridim/headless-cms";
331
+
332
+ interface Post { title: string; body: string } // your shape — clean
333
+ type StoredPost = Editable<Post>; // Post & { id: string; collection: string }
166
334
  ```
167
335
 
336
+ ## Styling: bring your own look
337
+
338
+ The package ships **no styles**. Skin the primitives in your app — for example, a
339
+ thin wrapper that applies your design system:
340
+
341
+ ```tsx
342
+ // components/EditableTitle.tsx
343
+ import { ContentEditSpan } from "@dalgoridim/headless-cms/client";
344
+
345
+ export const EditableTitle = (props) => (
346
+ <ContentEditSpan
347
+ {...props}
348
+ className="text-4xl font-bold data-[cms-focused]:ring-2 data-[cms-focused]:ring-blue-500"
349
+ />
350
+ );
351
+ ```
352
+
353
+ This keeps your design isolated from the engine and lets the package upgrade
354
+ without ever changing how your site looks.
355
+
356
+ ## Documentation
357
+
358
+ - **[README](./README.md)** — this guide: install and usage.
359
+ - **[ARCHITECTURE.md](./ARCHITECTURE.md)** — design rationale and internals
360
+ (bundle boundaries, backend parity, the hybrid Postgres model, headless UI).
361
+
168
362
  ## Build
169
363
 
170
364
  ```bash
@@ -174,5 +368,5 @@ npm run typecheck # tsc --noEmit
174
368
 
175
369
  ## Stability
176
370
 
177
- Pre-`1.0`: the API may change between minor versions as the engine evolves.
178
- Pin an exact version if you depend on it in production.
371
+ Pre-`1.0`: the API may change between minor versions as the engine evolves. Pin an
372
+ exact version if you depend on it in production.
@@ -51,13 +51,22 @@ __export(firestore_exports, {
51
51
  });
52
52
  module.exports = __toCommonJS(firestore_exports);
53
53
  var import_firebase_admin = __toESM(require("firebase-admin"), 1);
54
+
55
+ // src/types.ts
56
+ function isFilterGroup(c) {
57
+ return typeof c === "object" && c !== null && "or" in c;
58
+ }
59
+
60
+ // src/adapters/firestore/index.ts
54
61
  var OP_MAP = {
55
62
  eq: "==",
63
+ ne: "!=",
56
64
  lt: "<",
57
65
  lte: "<=",
58
66
  gt: ">",
59
67
  gte: ">=",
60
- in: "in"
68
+ in: "in",
69
+ nin: "not-in"
61
70
  };
62
71
  function serialize(data) {
63
72
  if (Array.isArray(data)) {
@@ -106,12 +115,24 @@ var FirestoreDataAdapter = class {
106
115
  if (!q) {
107
116
  return ref.orderBy(this.defaultOrderByField, "desc");
108
117
  }
109
- for (const f of (_a = q.filters) != null ? _a : []) {
110
- ref = ref.where(f.field, OP_MAP[f.op], f.value);
118
+ for (const c of (_a = q.filters) != null ? _a : []) {
119
+ if (isFilterGroup(c)) {
120
+ throw new Error(
121
+ "FirestoreDataAdapter does not support OR filter groups. Use a backend with richer query support (e.g. Postgres) or split into separate reads."
122
+ );
123
+ }
124
+ const op = OP_MAP[c.op];
125
+ if (!op) {
126
+ throw new Error(
127
+ `FirestoreDataAdapter does not support the '${c.op}' operator` + (c.op === "contains" ? " (no native substring search)." : ".")
128
+ );
129
+ }
130
+ ref = ref.where(c.field, op, c.value);
111
131
  }
112
132
  for (const o of (_b = q.orderBy) != null ? _b : []) {
113
133
  ref = ref.orderBy(o.field, o.direction);
114
134
  }
135
+ if (q.offset != null) ref = ref.offset(q.offset);
115
136
  if (q.limit != null) ref = ref.limit(q.limit);
116
137
  return ref;
117
138
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/adapters/firestore/index.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\";\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\nconst OP_MAP: Record<QueryFilterOp, WhereFilterOp> = {\n eq: \"==\",\n lt: \"<\",\n lte: \"<=\",\n gt: \">\",\n gte: \">=\",\n in: \"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 f of q.filters ?? []) {\n ref = ref.where(f.field, OP_MAP[f.op], f.value);\n }\n for (const o of q.orderBy ?? []) {\n ref = ref.orderBy(o.field, o.direction);\n }\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"],"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 * 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"]}
@@ -20,13 +20,22 @@ var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b));
20
20
 
21
21
  // src/adapters/firestore/index.ts
22
22
  import admin from "firebase-admin";
23
+
24
+ // src/types.ts
25
+ function isFilterGroup(c) {
26
+ return typeof c === "object" && c !== null && "or" in c;
27
+ }
28
+
29
+ // src/adapters/firestore/index.ts
23
30
  var OP_MAP = {
24
31
  eq: "==",
32
+ ne: "!=",
25
33
  lt: "<",
26
34
  lte: "<=",
27
35
  gt: ">",
28
36
  gte: ">=",
29
- in: "in"
37
+ in: "in",
38
+ nin: "not-in"
30
39
  };
31
40
  function serialize(data) {
32
41
  if (Array.isArray(data)) {
@@ -75,12 +84,24 @@ var FirestoreDataAdapter = class {
75
84
  if (!q) {
76
85
  return ref.orderBy(this.defaultOrderByField, "desc");
77
86
  }
78
- for (const f of (_a = q.filters) != null ? _a : []) {
79
- ref = ref.where(f.field, OP_MAP[f.op], f.value);
87
+ for (const c of (_a = q.filters) != null ? _a : []) {
88
+ if (isFilterGroup(c)) {
89
+ throw new Error(
90
+ "FirestoreDataAdapter does not support OR filter groups. Use a backend with richer query support (e.g. Postgres) or split into separate reads."
91
+ );
92
+ }
93
+ const op = OP_MAP[c.op];
94
+ if (!op) {
95
+ throw new Error(
96
+ `FirestoreDataAdapter does not support the '${c.op}' operator` + (c.op === "contains" ? " (no native substring search)." : ".")
97
+ );
98
+ }
99
+ ref = ref.where(c.field, op, c.value);
80
100
  }
81
101
  for (const o of (_b = q.orderBy) != null ? _b : []) {
82
102
  ref = ref.orderBy(o.field, o.direction);
83
103
  }
104
+ if (q.offset != null) ref = ref.offset(q.offset);
84
105
  if (q.limit != null) ref = ref.limit(q.limit);
85
106
  return ref;
86
107
  }