@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.
- package/ARCHITECTURE.md +125 -0
- package/README.md +242 -48
- package/dist/adapters/firestore/index.cjs +24 -3
- package/dist/adapters/firestore/index.cjs.map +1 -1
- package/dist/adapters/firestore/index.js +24 -3
- package/dist/adapters/firestore/index.js.map +1 -1
- package/dist/adapters/postgres/index.cjs +37 -11
- package/dist/adapters/postgres/index.cjs.map +1 -1
- package/dist/adapters/postgres/index.js +37 -11
- package/dist/adapters/postgres/index.js.map +1 -1
- package/dist/client/index.cjs +94 -543
- package/dist/client/index.cjs.map +1 -1
- package/dist/client/index.d.cts +89 -26
- package/dist/client/index.d.ts +89 -26
- package/dist/client/index.js +96 -547
- package/dist/client/index.js.map +1 -1
- package/dist/index.cjs +16 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +81 -16
- package/dist/index.d.ts +81 -16
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/server/index.cjs +63 -9
- package/dist/server/index.cjs.map +1 -1
- package/dist/server/index.d.cts +35 -5
- package/dist/server/index.d.ts +35 -5
- package/dist/server/index.js +61 -8
- package/dist/server/index.js.map +1 -1
- package/package.json +8 -11
package/ARCHITECTURE.md
ADDED
|
@@ -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.
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
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
|
-
|
|
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`, `
|
|
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
|
|
35
|
-
| `.../storage/cloudinary
|
|
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
|
-
##
|
|
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)`
|
|
48
|
-
-
|
|
49
|
-
|
|
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
|
-
##
|
|
64
|
+
## Quick start (Next.js App Router)
|
|
53
65
|
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
+
### 3. Providers (client)
|
|
94
106
|
|
|
95
107
|
```tsx
|
|
96
108
|
"use client";
|
|
97
|
-
import { PageProvider
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
124
|
-
`
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
159
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
|
110
|
-
|
|
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":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,4BAAkB;;;ACuGX,SAAS,cAAc,GAA0C;AACtE,SAAO,OAAO,MAAM,YAAY,MAAM,QAAQ,QAAQ;AACxD;;;AD9EA,IAAM,SAAwD;AAAA,EAC5D,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,KAAK;AAAA,EACL,IAAI;AAAA,EACJ,KAAK;AAAA,EACL,IAAI;AAAA,EACJ,KAAK;AACP;AAGA,SAAS,UAAa,MAAY;AAChC,MAAI,MAAM,QAAQ,IAAI,GAAG;AACvB,WAAO,KAAK,IAAI,SAAS;AAAA,EAC3B;AACA,MAAI,QAAQ,OAAO,SAAS,UAAU;AACpC,UAAM,MAAM;AACZ,QAAI,cAAc,OAAO,kBAAkB,KAAK;AAC9C,aAAO,IAAI;AAAA,QACR,IAAI,WAAsB,MAAQ,IAAI,eAA0B;AAAA,MACnE,EAAE,YAAY;AAAA,IAChB;AACA,QAAI,OAAQ,IAA6B,WAAW,YAAY;AAC9D,aAAQ,IAA+B,OAAO,EAAE,YAAY;AAAA,IAC9D;AACA,UAAM,MAA+B,CAAC;AACtC,eAAW,OAAO,IAAK,KAAI,GAAG,IAAI,UAAU,IAAI,GAAG,CAAC;AACpD,WAAO;AAAA,EACT;AACA,SAAO;AACT;AAEO,IAAM,uBAAN,MAAkD;AAAA,EAIvD,YAAY,SAAiC,CAAC,GAAG;AAhEnD;AAiEI,SAAK,uBAAsB,YAAO,wBAAP,YAA8B;AAEzD,QAAI,OAAO,IAAI;AACb,WAAK,KAAK,OAAO;AACjB;AAAA,IACF;AAEA,QAAI,CAAC,sBAAAA,QAAM,KAAK,QAAQ;AACtB,YAAM,KAAI,YAAO,gBAAP,YAAsB,CAAC;AACjC,4BAAAA,QAAM,cAAc;AAAA,QAClB,YAAY,sBAAAA,QAAM,WAAW,KAAK;AAAA,UAChC,WAAW,EAAE;AAAA,UACb,aAAa,EAAE;AAAA,UACf,YAAY,EAAE;AAAA,QAChB,CAAC;AAAA,QACD,aAAa,EAAE;AAAA,MACjB,CAAC;AAAA,IACH;AACA,SAAK,KAAK,sBAAAA,QAAM,UAAU;AAAA,EAC5B;AAAA,EAEQ,WAAW,YAAoB,GAA2B;AAtFpE;AAuFI,QAAI,MAAsB,KAAK,GAAG,WAAW,UAAU;AAEvD,QAAI,CAAC,GAAG;AACN,aAAO,IAAI,QAAQ,KAAK,qBAAqB,MAAM;AAAA,IACrD;AAEA,eAAW,MAAK,OAAE,YAAF,YAAa,CAAC,GAAG;AAC/B,UAAI,cAAc,CAAC,GAAG;AACpB,cAAM,IAAI;AAAA,UACR;AAAA,QAEF;AAAA,MACF;AACA,YAAM,KAAK,OAAO,EAAE,EAAE;AACtB,UAAI,CAAC,IAAI;AACP,cAAM,IAAI;AAAA,UACR,8CAA8C,EAAE,EAAE,gBAC/C,EAAE,OAAO,aAAa,mCAAmC;AAAA,QAC9D;AAAA,MACF;AACA,YAAM,IAAI,MAAM,EAAE,OAAO,IAAI,EAAE,KAAK;AAAA,IACtC;AACA,eAAW,MAAK,OAAE,YAAF,YAAa,CAAC,GAAG;AAC/B,YAAM,IAAI,QAAQ,EAAE,OAAO,EAAE,SAAS;AAAA,IACxC;AACA,QAAI,EAAE,UAAU,KAAM,OAAM,IAAI,OAAO,EAAE,MAAM;AAC/C,QAAI,EAAE,SAAS,KAAM,OAAM,IAAI,MAAM,EAAE,KAAK;AAC5C,WAAO;AAAA,EACT;AAAA,EAEA,MAAM,gBACJ,YACA,GACiC;AACjC,UAAM,OAAO,MAAM,KAAK,WAAW,YAAY,CAAC,EAAE,IAAI;AACtD,WAAO,KAAK,KAAK;AAAA,MAAI,CAAC,QACpB,UAAU,iBAAE,IAAI,IAAI,MAAQ,IAAI,KAAK,EAAS;AAAA,IAChD;AAAA,EACF;AAAA,EAEA,MAAM,UACJ,YACA,IACsC;AACtC,UAAM,MAAM,MAAM,KAAK,GAAG,WAAW,UAAU,EAAE,IAAI,EAAE,EAAE,IAAI;AAC7D,QAAI,CAAC,IAAI,OAAQ,QAAO;AACxB,WAAO,UAAU,iBAAE,IAAI,IAAI,MAAQ,IAAI,KAAK,EAAS;AAAA,EACvD;AAAA,EAEA,MAAM,OACJ,YACA,MAC6B;AAC7B,UAAM,MAAM,KAAK,GAAG,WAAW,UAAU,EAAE,IAAI;AAC/C,UAAM,IAAI,IAAI,iCAAK,OAAL,EAAW,WAAW,oBAAI,KAAK,GAAG,WAAW,oBAAI,KAAK,EAAE,EAAC;AACvE,WAAO,iBAAE,IAAI,IAAI,MAAQ;AAAA,EAC3B;AAAA,EAEA,MAAM,aACJ,YACA,IACA,MAC6B;AAC7B,UAAM,KAAK,GACR,WAAW,UAAU,EACrB,IAAI,EAAE,EACN,IAAI,iCAAK,OAAL,EAAW,WAAW,oBAAI,KAAK,GAAG,WAAW,oBAAI,KAAK,EAAE,EAAC;AAChE,WAAO,iBAAE,MAAQ;AAAA,EACnB;AAAA,EAEA,MAAM,OACJ,YACA,IACA,MACe;AACf,UAAM,KAAK,GACR,WAAW,UAAU,EACrB,IAAI,EAAE,EACN,OAAO,iCAAK,OAAL,EAAW,WAAW,oBAAI,KAAK,EAAE,EAAC;AAAA,EAC9C;AAAA,EAEA,MAAM,OACJ,YACA,IACA,MACe;AACf,UAAM,KAAK,GACR,WAAW,UAAU,EACrB,IAAI,EAAE,EACN,IAAI,iCAAK,OAAL,EAAW,WAAW,oBAAI,KAAK,EAAE,IAAG,EAAE,OAAO,KAAK,CAAC;AAAA,EAC5D;AAAA,EAEA,MAAM,OAAO,YAAoB,IAA2B;AAC1D,UAAM,KAAK,GAAG,WAAW,UAAU,EAAE,IAAI,EAAE,EAAE,OAAO;AAAA,EACtD;AACF;","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
|
|
79
|
-
|
|
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
|
}
|