@dalgoridim/headless-cms 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +126 -0
- package/README.md +238 -48
- package/REDESIGN.md +250 -0
- 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 +5 -10
package/REDESIGN.md
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# Headless CMS — "Harness, Not Framework" Redesign
|
|
2
|
+
|
|
3
|
+
> Full spec for the "harness, not framework" redesign. Living document — kept in
|
|
4
|
+
> sync as the two tracks land.
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
`@dalgoridim/headless-cms` was extracted from `portfolio-2025`. An audit of what
|
|
9
|
+
the package *forces* on its consumers found two layers with opposite philosophies:
|
|
10
|
+
|
|
11
|
+
- **Data / server layer — already free.** `Section` is schemaless
|
|
12
|
+
(`{ id, collection, [key]: any }`), the Postgres adapter never drops unmapped
|
|
13
|
+
fields (JSONB `documents` fallback + `extra` column), and `DataAdapter` is
|
|
14
|
+
generic. Good.
|
|
15
|
+
- **UI / client layer — a framework, not a harness.** The components ship a
|
|
16
|
+
finished, portfolio-specific look and hard dependencies. A consumer adopting
|
|
17
|
+
the package today **inherits portfolio-2025's visual design** and is forced
|
|
18
|
+
into Tailwind, a dark `neutral` palette, a `primary` design token,
|
|
19
|
+
`lucide-react`, `sonner`, and a bespoke text-mark syntax.
|
|
20
|
+
|
|
21
|
+
The goal: make the **whole** package a harness — wire behavior, impose nothing.
|
|
22
|
+
Two tracks, shipped together:
|
|
23
|
+
|
|
24
|
+
- **Track A** — de-framework-ize the UI (the philosophy fix; highest impact).
|
|
25
|
+
- **Track B** — close the four data-layer capability gaps (query power,
|
|
26
|
+
relations, loosened `id`/`collection`, auth flexibility).
|
|
27
|
+
|
|
28
|
+
### Decisions locked in with the user
|
|
29
|
+
- Package goes **pure headless**; the current dark/Tailwind look is **moved into
|
|
30
|
+
portfolio-2025's own shim components**, not kept in the package.
|
|
31
|
+
- API optimized for **DX**: `className`/`style` passthrough + `data-*` state
|
|
32
|
+
attributes + injectable deps (icons / notifier / markup parser); render-props
|
|
33
|
+
/ `asChild` only where it genuinely improves control.
|
|
34
|
+
- **Both tracks in one pass.**
|
|
35
|
+
|
|
36
|
+
### Guiding principles (apply to every change)
|
|
37
|
+
1. Ship **unstyled** by default — no Tailwind, no palette, no `primary` token.
|
|
38
|
+
2. Expose **styling hooks** — `className`, `style`, `data-*` state attributes,
|
|
39
|
+
and render-props/`asChild` for complex chrome.
|
|
40
|
+
3. Make deps **injectable, not baked** — icons, toasts, markup parser.
|
|
41
|
+
4. **No portfolio-specific code** in the package.
|
|
42
|
+
5. Never silently break portfolio — package + portfolio shims change in lockstep.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Current forcing inventory (what we are removing)
|
|
47
|
+
|
|
48
|
+
| Forced thing | Location | Resolution |
|
|
49
|
+
|---|---|---|
|
|
50
|
+
| Tailwind mandatory (`cn`, utility strings) | all client components, `client/utils.ts` | Strip utility strings; `cn`/`tailwind-merge` leaves core. |
|
|
51
|
+
| Dark `neutral` palette + ring offsets | `ContentEditSpan`, `EditableImage`, `MarkdownEditor` | Move to portfolio shims. |
|
|
52
|
+
| `primary` token (`var(--color-primary)`, `bg-primary`) | `ContentEditSpan.tsx:103`, `EditableImage`, `MarkdownEditor` | Removed from package; portfolio keeps it in its shims. |
|
|
53
|
+
| `lucide-react` + `sonner` baked in | `EditableImage`, `MarkdownEditor`, `PageProvider` default notifier | Drop from hard deps; inject; portfolio supplies them. |
|
|
54
|
+
| Bespoke text-mark syntax (`^^primary^^`, `~~br~~`, `__underline__`) | `ContentEditSpan.tsx:30` `PATTERNS` | Make parser/renderer injectable with a default. |
|
|
55
|
+
| Portfolio leftovers: `collection = "portfolio"` default, `ProjectContentEditor` | `ContentEditSpan.tsx:151`, `MarkdownEditor.tsx:293` | Delete from package; move `ProjectContentEditor` to portfolio. |
|
|
56
|
+
| Hardcoded English copy | `EditableImage`, `MarkdownEditor` | Replace with props/children defaults. |
|
|
57
|
+
| `id` + `collection` forced on content type | `types.ts:12` | Split internal addressing from user's `T` (Track B3). |
|
|
58
|
+
| Tiny query language | `types.ts:48` | Extend neutrally (Track B1). |
|
|
59
|
+
| `AuthIdentity` forces `userId`+`isAdmin` | `types.ts:92` | Minimal contract + open payload + `authorize` predicate (Track B4). |
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Track A — Headless UI
|
|
64
|
+
|
|
65
|
+
### A1. `PageProvider` (`src/client/PageProvider.tsx`)
|
|
66
|
+
- `Notifier` is already injectable — keep. **Change the default** from a `sonner`
|
|
67
|
+
toast sink to a dependency-free console/no-op default so `sonner` is no longer
|
|
68
|
+
required. Portfolio passes a sonner-backed notifier from its shim.
|
|
69
|
+
- No other behavior change; this file is mostly backend-agnostic already.
|
|
70
|
+
|
|
71
|
+
### A2. `ContentEditSpan` (`src/client/ContentEditSpan.tsx`)
|
|
72
|
+
- Remove `collection = "portfolio"` default → `collection` becomes required (or
|
|
73
|
+
resolved from an optional provider-level default-collection context).
|
|
74
|
+
- Extract the `PATTERNS` parser + `RenderStatic` into an **injectable markup
|
|
75
|
+
contract**: `parse(raw) => Node[]` + `render(nodes)`, defaulted to the current
|
|
76
|
+
implementation but overridable via prop or a `CmsMarkupContext`. Consumers who
|
|
77
|
+
want plain text or their own DSL drop in their own.
|
|
78
|
+
- Strip all Tailwind/`primary`/`neutral` classes. Keep `className` passthrough.
|
|
79
|
+
Emit **state via data attributes**: `data-cms-editable`, `data-editing`,
|
|
80
|
+
`data-focused` so consumers style with their own CSS.
|
|
81
|
+
- Keep the `as` prop; add `asChild` (lightweight Slot) so the consumer can supply
|
|
82
|
+
their own element/markup while the primitive wires `contentEditable`.
|
|
83
|
+
|
|
84
|
+
### A3. `EditableImage` (`src/client/EditableImage.tsx`)
|
|
85
|
+
- Keep the upload/url/pending-image behavior (it's correct and engine-wired).
|
|
86
|
+
- Remove `lucide` icons and all dark modal/overlay styling. Expose a
|
|
87
|
+
**render-prop API**: the component manages state and hands the consumer
|
|
88
|
+
`{ src, isEditing, saving, openFilePicker, setUrl, error }`; the consumer
|
|
89
|
+
renders the overlay/modal. Provide a minimal **unstyled default** render so the
|
|
90
|
+
simple case still works without writing chrome.
|
|
91
|
+
- Placeholder ("No image available") and modal copy become props/children.
|
|
92
|
+
|
|
93
|
+
### A4. `MarkdownEditor` (`src/client/MarkdownEditor.tsx`)
|
|
94
|
+
- This is the most styled file. Split into:
|
|
95
|
+
- **Headless core**: editor state + commands (`insertMarkdown`, preview
|
|
96
|
+
toggle, save/cancel, char count) exposed via hook/render-prop.
|
|
97
|
+
- The modal chrome, toolbar, icons (`lucide`), markdown guide, and
|
|
98
|
+
`react-markdown`/`remark-gfm` preview move to **portfolio**.
|
|
99
|
+
- `react-markdown` + `remark-gfm` leave the package's hard deps (preview is a
|
|
100
|
+
consumer concern; the core only manages text).
|
|
101
|
+
- **Delete `ProjectContentEditor`** from the package (portfolio-specific) → move
|
|
102
|
+
to portfolio.
|
|
103
|
+
|
|
104
|
+
### A5. `client/utils.ts` + deps
|
|
105
|
+
- `cn` relies on `tailwind-merge` (Tailwind-specific). Drop `tailwind-merge` from
|
|
106
|
+
the package; either remove `cn` from the public API or reduce it to a plain
|
|
107
|
+
`clsx` join. Tailwind-specific merging belongs in portfolio.
|
|
108
|
+
|
|
109
|
+
### A6. `package.json` dependency surgery
|
|
110
|
+
Move out of hard `dependencies` (→ removed from package; portfolio installs):
|
|
111
|
+
`lucide-react`, `react-markdown`, `remark-gfm`, `sonner`, `tailwind-merge`.
|
|
112
|
+
Keep only genuinely shared, non-opinion runtime deps (`clsx` at most). Update
|
|
113
|
+
`client/index.ts` exports (remove `ProjectContentEditor`; keep `cn` only if
|
|
114
|
+
retained).
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## Track B — Data-layer freedom
|
|
119
|
+
|
|
120
|
+
Touch points: `src/types.ts`, `src/adapters/postgres/index.ts`,
|
|
121
|
+
`src/adapters/firestore/index.ts` (read during impl), `src/server/*`.
|
|
122
|
+
|
|
123
|
+
### B1. Query power (`types.ts:48`, both adapters)
|
|
124
|
+
Extend the neutral `Query` without leaking any backend type:
|
|
125
|
+
- Ops: add `ne`, `nin`, `contains` (case-insensitive substring → SQL `ILIKE
|
|
126
|
+
%v%`), keep existing.
|
|
127
|
+
- **OR groups**: allow `filters` to nest — an `or: Filter[]` group alongside the
|
|
128
|
+
implicit top-level `AND`.
|
|
129
|
+
- **Pagination**: add `offset` next to `limit`.
|
|
130
|
+
- Implement fully in Postgres (`colExpr`/`OP_MAP` already structured for it).
|
|
131
|
+
Implement best-effort in Firestore and **throw a clear error** on ops the
|
|
132
|
+
backend can't honor (e.g. cross-field `OR`, `contains`) rather than returning
|
|
133
|
+
wrong results. Document the parity matrix.
|
|
134
|
+
|
|
135
|
+
### B2. Relations (schemaless-friendly)
|
|
136
|
+
- A reference is just a field holding an id (or `{ collection, id }`) or an array
|
|
137
|
+
of them — no schema change required to store one.
|
|
138
|
+
- Add an optional `populate?: string[]` to `Query` and a neutral resolver in the
|
|
139
|
+
engine that, after the primary fetch, **batch-loads referenced docs** via an
|
|
140
|
+
`in` query and inlines them. Backend-agnostic (works on JSONB + Firestore).
|
|
141
|
+
- Optional `RelationConfig` (field → target collection) so refs that are bare ids
|
|
142
|
+
know where to resolve.
|
|
143
|
+
|
|
144
|
+
### B3. Loosen `id` / `collection` (`types.ts:12`)
|
|
145
|
+
- Keep `id`/`collection` as the engine's **internal addressing**, but stop
|
|
146
|
+
forcing them onto the user's content type. Introduce `Editable<T> = T & {
|
|
147
|
+
id: string; collection: string }` used internally; public APIs accept the
|
|
148
|
+
user's clean `T`.
|
|
149
|
+
- Make the field names **configurable** at the adapter level (`idField`,
|
|
150
|
+
`collectionField`) for consumers whose records use `_id` / `slug`.
|
|
151
|
+
|
|
152
|
+
### B4. Auth flexibility (`types.ts:92`, `server/createAdminGate.ts`)
|
|
153
|
+
- `AuthIdentity` → minimal `{ isAdmin: boolean }` plus an open payload
|
|
154
|
+
(`[key: string]: unknown`; `userId`/`email` optional).
|
|
155
|
+
- `verifyRequest` may return any identity shape; the gate authorizes via an
|
|
156
|
+
injectable `authorize(identity, req) => boolean` predicate, **defaulting** to
|
|
157
|
+
`identity.isAdmin === true`. Lets consumers gate on roles/scopes without the
|
|
158
|
+
fixed contract.
|
|
159
|
+
- Client `CmsAuthState` stays minimal but becomes extensible.
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## Portfolio-2025 migration (lockstep)
|
|
164
|
+
|
|
165
|
+
Portfolio consumes the package only through **thin shims**, so the look moves
|
|
166
|
+
there cleanly:
|
|
167
|
+
|
|
168
|
+
- `lib/context/PageContent.tsx` — pass a **sonner-backed `notify`** to
|
|
169
|
+
`PageProvider` (restores toasts).
|
|
170
|
+
- `components/customs/ContentEditSpan.tsx` — wrap the headless primitive,
|
|
171
|
+
re-apply the dark ring/`primary` Tailwind classes and the default `collection`.
|
|
172
|
+
- `components/customs/EditableImage.tsx` — supply the styled overlay/modal +
|
|
173
|
+
`lucide` icons via the render-prop.
|
|
174
|
+
- `components/customs/MarkdownEditor.tsx` — supply the modal chrome, toolbar,
|
|
175
|
+
`lucide` icons, `react-markdown` preview, guide; **define `ProjectContentEditor`
|
|
176
|
+
here**.
|
|
177
|
+
- Install in portfolio what left the package: `lucide-react`, `sonner`,
|
|
178
|
+
`react-markdown`, `remark-gfm`, `tailwind-merge` (most already present).
|
|
179
|
+
- `lib/cms/server.ts` / auth shim — adjust only if `AuthIdentity` field changes
|
|
180
|
+
ripple (expected: none, contract is widened not narrowed).
|
|
181
|
+
|
|
182
|
+
Portfolio's `app/globals.css` already defines `--primary` / `--color-primary`,
|
|
183
|
+
so its shims keep the exact current look.
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## Sequencing (so nothing breaks mid-flight)
|
|
188
|
+
|
|
189
|
+
1. **Track B** first (additive, low-risk): `types.ts` Query/Auth widening, then
|
|
190
|
+
Postgres + Firestore adapter impls. Widening contracts won't break portfolio.
|
|
191
|
+
2. **Track A** package changes: provider default notifier, headless
|
|
192
|
+
ContentEditSpan / EditableImage / MarkdownEditor, dep surgery, exports.
|
|
193
|
+
3. **Portfolio shims** updated in the same change set to restore look + behavior.
|
|
194
|
+
4. Drop this doc into the package root as `REDESIGN.md`.
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## Verification
|
|
199
|
+
|
|
200
|
+
- **Package**: `npm run build` (tsup) and `npm run typecheck` in
|
|
201
|
+
`headless-cms` — both clean.
|
|
202
|
+
- **Portfolio typecheck/build**: `npm run build` in `portfolio-2025` resolves all
|
|
203
|
+
shims against the new headless API.
|
|
204
|
+
- **Postgres proof** (also a memory milestone): run `scripts/seed-postgres.ts`,
|
|
205
|
+
then exercise new query ops (`contains`, `or`, `offset`) and a `populate`
|
|
206
|
+
round-trip against the local `documents` table.
|
|
207
|
+
- **Manual UI** (`/verify` or `/run`): in portfolio admin/edit mode, confirm
|
|
208
|
+
inline text edit, image upload + external-URL, and the markdown modal all still
|
|
209
|
+
look and behave as before — proving the look fully survived the move to shims.
|
|
210
|
+
- **Headless smoke**: render `ContentEditSpan` / `EditableImage` with **no**
|
|
211
|
+
Tailwind/styles to confirm they work unstyled (harness proof).
|
|
212
|
+
|
|
213
|
+
## Open risks
|
|
214
|
+
- **Firestore query parity** — it cannot honor cross-field `OR` / `contains`
|
|
215
|
+
generally. Plan: throw explicit "unsupported by this adapter" errors and ship a
|
|
216
|
+
documented parity matrix rather than silent wrong results.
|
|
217
|
+
- **`asChild`/render-prop surface** — adds API; mitigated by keeping unstyled
|
|
218
|
+
defaults so trivial cases stay one-liners.
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
## Status (landed)
|
|
223
|
+
|
|
224
|
+
- **Track B1 (query power)** — done. `QueryFilterOp` gains `ne`/`nin`/`contains`;
|
|
225
|
+
`Query` gains OR groups (`{ or: [...] }`) and `offset`. Postgres implements all;
|
|
226
|
+
Firestore throws clear errors on `contains` / OR groups.
|
|
227
|
+
- **Track B2 (relations)** — done. `resolveRelations(adapter, docs, opts)` in
|
|
228
|
+
`src/server/relations.ts` (exported from `/server`); `Query.populate`,
|
|
229
|
+
`Ref`, `RelationConfig` added.
|
|
230
|
+
- **Track B3 (id/collection)** — **type-level done** (`Editable<T>` +
|
|
231
|
+
`EntityAddress`; `T` unconstrained). **Deferred:** per-adapter configurable
|
|
232
|
+
physical `idField`/`collectionField` renaming — bigger SQL change, left as a
|
|
233
|
+
follow-up to avoid destabilizing the working schemaless path.
|
|
234
|
+
- **Track B4 (auth)** — done. `AuthIdentity` widened to `{ isAdmin } + open
|
|
235
|
+
payload`; injectable `AuthorizeFn` on `createAdminGate` / `createCmsHandlers`.
|
|
236
|
+
- **Track A (headless UI)** — done. `ContentEditSpan` (injectable `renderValue`,
|
|
237
|
+
`data-cms-*` attrs, no styles), `EditableImage` (render-prop), `MarkdownEditor`
|
|
238
|
+
→ `useMarkdownEditor` hook. Removed `ProjectContentEditor`, `cn`/`utils.ts`,
|
|
239
|
+
and all of `lucide-react`/`sonner`/`react-markdown`/`remark-gfm`/`tailwind-merge`
|
|
240
|
+
from package deps (now **zero** runtime deps).
|
|
241
|
+
- **Portfolio shims** — restyled to restore the exact look: `ContentEditSpan`,
|
|
242
|
+
`EditableImage`, `MarkdownEditor`+`ProjectContentEditor`, sonner `notify`.
|
|
243
|
+
|
|
244
|
+
Verified: package `typecheck` + `build` clean; portfolio `tsc --noEmit` clean
|
|
245
|
+
against the new API; portfolio **production build passes** (firebase mode, all
|
|
246
|
+
routes); **Postgres round-trip 12/12** — `contains`, OR groups, `ne`/`nin`,
|
|
247
|
+
`offset`+order (text + typed numeric), and `populate` via `RelationConfig`.
|
|
248
|
+
Caveat surfaced: numeric fields in the schemaless JSONB `documents` table order
|
|
249
|
+
as text (`data->>` is text); register a typed collection for true numeric
|
|
250
|
+
ordering/comparison. Not yet run: manual edit-mode UI pass (needs admin login).
|
|
@@ -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
|
}
|
|
@@ -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":[]}
|
|
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":[]}
|
|
@@ -51,13 +51,20 @@ __export(postgres_exports, {
|
|
|
51
51
|
module.exports = __toCommonJS(postgres_exports);
|
|
52
52
|
var import_pg = require("pg");
|
|
53
53
|
var import_node_crypto = require("crypto");
|
|
54
|
-
|
|
54
|
+
|
|
55
|
+
// src/types.ts
|
|
56
|
+
function isFilterGroup(c) {
|
|
57
|
+
return typeof c === "object" && c !== null && "or" in c;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// src/adapters/postgres/index.ts
|
|
61
|
+
var BINARY_OP = {
|
|
55
62
|
eq: "=",
|
|
63
|
+
ne: "<>",
|
|
56
64
|
lt: "<",
|
|
57
65
|
lte: "<=",
|
|
58
66
|
gt: ">",
|
|
59
|
-
gte: ">="
|
|
60
|
-
in: "= ANY"
|
|
67
|
+
gte: ">="
|
|
61
68
|
};
|
|
62
69
|
var SQL_TYPE = {
|
|
63
70
|
text: "text",
|
|
@@ -165,25 +172,44 @@ var PostgresDataAdapter = class {
|
|
|
165
172
|
}
|
|
166
173
|
return `data->>${literal(field)}`;
|
|
167
174
|
};
|
|
175
|
+
const renderFilter = (f) => {
|
|
176
|
+
const col = colExpr(f.field);
|
|
177
|
+
if (f.op === "in" || f.op === "nin") {
|
|
178
|
+
params.push(f.value);
|
|
179
|
+
const cmp = f.op === "in" ? "= ANY" : "<> ALL";
|
|
180
|
+
return `${col} ${cmp}($${params.length})`;
|
|
181
|
+
}
|
|
182
|
+
if (f.op === "contains") {
|
|
183
|
+
params.push(`%${String(f.value)}%`);
|
|
184
|
+
return `${col} ILIKE $${params.length}`;
|
|
185
|
+
}
|
|
186
|
+
const op = BINARY_OP[f.op];
|
|
187
|
+
if (!op) throw new Error(`Unsupported query op: ${f.op}`);
|
|
188
|
+
params.push(f.value);
|
|
189
|
+
return `${col} ${op} $${params.length}`;
|
|
190
|
+
};
|
|
191
|
+
const renderCondition = (c) => {
|
|
192
|
+
if (isFilterGroup(c)) {
|
|
193
|
+
if (!c.or.length) return "TRUE";
|
|
194
|
+
return `(${c.or.map(renderFilter).join(" OR ")})`;
|
|
195
|
+
}
|
|
196
|
+
return renderFilter(c);
|
|
197
|
+
};
|
|
168
198
|
if (!cfg) {
|
|
169
199
|
params.push(collection);
|
|
170
200
|
where.push(`collection = $${params.length}`);
|
|
171
201
|
}
|
|
172
|
-
for (const
|
|
173
|
-
|
|
174
|
-
if (f.op === "in") {
|
|
175
|
-
where.push(`${colExpr(f.field)} = ANY($${params.length})`);
|
|
176
|
-
} else {
|
|
177
|
-
where.push(`${colExpr(f.field)} ${OP_MAP[f.op]} $${params.length}`);
|
|
178
|
-
}
|
|
202
|
+
for (const c of (_a = q == null ? void 0 : q.filters) != null ? _a : []) {
|
|
203
|
+
where.push(renderCondition(c));
|
|
179
204
|
}
|
|
180
205
|
const orderBy = ((_b = q == null ? void 0 : q.orderBy) != null ? _b : []).map((o) => `${colExpr(o.field)} ${o.direction === "asc" ? "ASC" : "DESC"}`).join(", ") || "created_at DESC";
|
|
181
206
|
const limit = (q == null ? void 0 : q.limit) != null ? ` LIMIT ${Number(q.limit)}` : "";
|
|
207
|
+
const offset = (q == null ? void 0 : q.offset) != null ? ` OFFSET ${Number(q.offset)}` : "";
|
|
182
208
|
const whereSql = where.length ? ` WHERE ${where.join(" AND ")}` : "";
|
|
183
209
|
const table = cfg ? cfg.table : this.documentsTable;
|
|
184
210
|
const select = cfg ? "*" : "id, data";
|
|
185
211
|
const { rows } = await this.pool.query(
|
|
186
|
-
`SELECT ${select} FROM ${ident(table)}${whereSql} ORDER BY ${orderBy}${limit}`,
|
|
212
|
+
`SELECT ${select} FROM ${ident(table)}${whereSql} ORDER BY ${orderBy}${limit}${offset}`,
|
|
187
213
|
params
|
|
188
214
|
);
|
|
189
215
|
if (cfg) {
|