foldkit-cms-drizzle 0.1.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/LICENSE +21 -0
- package/README.md +245 -0
- package/dist/index.d.mts +1722 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +714 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +64 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Patrick Glenn
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# foldkit-cms-drizzle
|
|
2
|
+
|
|
3
|
+
The server half of [`foldkit-cms`](../cms/README.md), over
|
|
4
|
+
[`foldkit-remote-drizzle`](../remote-drizzle/README.md). It keeps an author's
|
|
5
|
+
unpublished work in three tables beside your own, and it is where the **audience
|
|
6
|
+
boundary** is enforced: who is not an author is refused entries, drafts and
|
|
7
|
+
revisions outright, and sees of your content only what is published.
|
|
8
|
+
|
|
9
|
+
> **Status: every operation.** Saving and discarding a draft, publishing and
|
|
10
|
+
> unpublishing, scheduling, archiving, restoring a revision, the worklist, an
|
|
11
|
+
> entry's derived state, and the boundary. In-app preview and an example are what
|
|
12
|
+
> is left of [the design](../../docs/design/cms-DESIGN.md#13-build-order).
|
|
13
|
+
> Not on npm. SQLite and Postgres; MySQL has no `returning`, which the conflict
|
|
14
|
+
> rule needs.
|
|
15
|
+
|
|
16
|
+
## What it owns
|
|
17
|
+
|
|
18
|
+
| Fact | Owner |
|
|
19
|
+
| --- | --- |
|
|
20
|
+
| Published content | your tables, bound with `bind` as any are |
|
|
21
|
+
| Unpublished work | `cms_entries`, `cms_drafts`, `cms_revisions` |
|
|
22
|
+
| Which rows of those a principal may see | this package: none, unless they are an author |
|
|
23
|
+
| Which rows of your content a visitor may see | your binding's `visible`, which `published(...)` makes |
|
|
24
|
+
| Who is an author, and who may make a transition | your `isAuthor` and `allow` |
|
|
25
|
+
| What publishing a post *does* | your own `create` and `update` handlers |
|
|
26
|
+
| That a publish is whole | this package, inside the `transaction` you name |
|
|
27
|
+
| The time | your `now`, which defaults to the server's clock |
|
|
28
|
+
|
|
29
|
+
## Mental model
|
|
30
|
+
|
|
31
|
+
A table is read four ways: by id, as the children of a relation, as the target
|
|
32
|
+
of a ref, and through a query. A rule on one of them leaves three open. So the
|
|
33
|
+
boundary is not a filter this package adds somewhere; it is a
|
|
34
|
+
[`visible`](../remote-drizzle/README.md#which-rows-a-principal-may-see) rule on
|
|
35
|
+
each binding, which every one of those paths applies.
|
|
36
|
+
|
|
37
|
+
```text
|
|
38
|
+
an author's principal ──> entries, drafts, revisions, every content row
|
|
39
|
+
a visitor's principal ──> published content rows, and nothing else
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
pnpm add effect drizzle-orm foldkit-cms foldkit-cms-drizzle foldkit-remote-drizzle foldkit-remote-server
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Example
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { CmsServer, Transaction, published, sqliteTables } from 'foldkit-cms-drizzle'
|
|
52
|
+
|
|
53
|
+
const cmsTables = sqliteTables() // or pgTables(); add them to your schema and migrations
|
|
54
|
+
|
|
55
|
+
const Db = bind(Blog, {
|
|
56
|
+
Post: {
|
|
57
|
+
table: posts,
|
|
58
|
+
// A visitor sees what is published; an author sees every row.
|
|
59
|
+
visible: published(posts.publishedAt, isAuthor),
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// Your own handlers of the two mutations the content type publishes through.
|
|
64
|
+
const CreatePost = RemoteServer.mutation(Posts.publish.create, ({ input }) => /* insert */)
|
|
65
|
+
const UpdatePost = RemoteServer.mutation(Posts.publish.update, ({ input }) => /* update */)
|
|
66
|
+
|
|
67
|
+
const cms = CmsServer.make({
|
|
68
|
+
tables: cmsTables,
|
|
69
|
+
content: [{ type: Posts, binding: Db.Post, create: CreatePost, update: UpdatePost }],
|
|
70
|
+
transaction: Transaction.statements, // one connection; Transaction.drizzle for Postgres
|
|
71
|
+
isAuthor: principal => principal?.role === 'author',
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
RemoteServer.make({
|
|
75
|
+
entities: [...cms.sources, source(Db.Author)],
|
|
76
|
+
queries: [...cms.queries],
|
|
77
|
+
mutations: [...cms.mutations],
|
|
78
|
+
})
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
- **Register `cms.sources`, not `source(Db.Post)`.** They are the content types'
|
|
82
|
+
sources and the CMS's own, each behind the boundary.
|
|
83
|
+
- **A content type that can be unpublished must be bound with a `visible` rule.**
|
|
84
|
+
`CmsServer.make` throws for a type with a `published` role whose binding shows
|
|
85
|
+
every row to everyone. It checks that a rule is there; whether the rule is
|
|
86
|
+
right is yours to say, since only you know what a principal is.
|
|
87
|
+
- A content type with no `published` role has nothing to hide, and needs none.
|
|
88
|
+
- **`create` and `update` go to the CMS, not to `RemoteServer.make`.** Registered
|
|
89
|
+
there too, they are a way to publish with no draft, no revision and no `allow`.
|
|
90
|
+
|
|
91
|
+
## Saving a draft
|
|
92
|
+
|
|
93
|
+
`CmsSaveDraft` writes the working copy, valid or not. The client names something
|
|
94
|
+
new (`Cms.newEntryId()`), and the first save of an id nobody has makes the entry.
|
|
95
|
+
|
|
96
|
+
- **A save names what it was made from** (`basedOn`, the draft's `updatedAt`). A
|
|
97
|
+
newer one on the server means someone else saved in between, and the save is
|
|
98
|
+
refused with `CmsConflict: ...` instead of undoing their work. It is one
|
|
99
|
+
statement, compare and set, so two saves cannot both win.
|
|
100
|
+
- A draft is bounded: `maxDraftSize` characters of JSON, values and Model
|
|
101
|
+
together, one million by default. More is refused, and nothing is written.
|
|
102
|
+
- Two saves in the same instant still get different `updatedAt`s, so the next
|
|
103
|
+
save can tell them apart.
|
|
104
|
+
- It answers with the entry and the draft as patches, so the client holds both
|
|
105
|
+
with no refetch. Every operation patches the entry with its `state` and its
|
|
106
|
+
`revision`, so what is on screen follows what was done.
|
|
107
|
+
- Refused: a type the server does not know, an entry that is not there or is of
|
|
108
|
+
another type, an archived entry, a principal that is not an author, and an
|
|
109
|
+
author your `allow` refuses.
|
|
110
|
+
|
|
111
|
+
`CmsDiscardDraft` removes the working copy. A published entry is left as it was
|
|
112
|
+
published. An entry that was never published has nothing left, so it is removed
|
|
113
|
+
too, and the client is told both are gone.
|
|
114
|
+
|
|
115
|
+
## Publishing
|
|
116
|
+
|
|
117
|
+
`CmsPublish` runs your handler with the draft's value: `create` when the entry
|
|
118
|
+
has no row, `update`, with the row's id added, when it has. Around it, in one
|
|
119
|
+
transaction: the `published` column is set if it was empty, the revision is
|
|
120
|
+
appended, the draft is removed, and the entry is told its row. A handler that
|
|
121
|
+
fails after writing leaves nothing behind.
|
|
122
|
+
|
|
123
|
+
- **Every operation is one transaction**, not only a publish: a save that made an
|
|
124
|
+
entry and could not make its draft leaves neither.
|
|
125
|
+
- **You name the transaction**, because Drizzle's differ by driver.
|
|
126
|
+
`Transaction.statements` is `begin` and `commit` as statements, for a database
|
|
127
|
+
that is one connection (a SQLite file). `Transaction.drizzle` is
|
|
128
|
+
`database.transaction`, for a driver whose transactions are asynchronous
|
|
129
|
+
(Postgres, libSQL); your handlers then get the transaction as their
|
|
130
|
+
`DrizzleDatabase`. Statements over a pool would not roll back.
|
|
131
|
+
- **One connection holds one transaction**, so under `Transaction.statements` this
|
|
132
|
+
package's operations take turns at the database. Without that, a save arriving
|
|
133
|
+
while a publish is failing would be rolled back with it, after it was reported
|
|
134
|
+
done. A write of your own on the same connection does not take a turn unless
|
|
135
|
+
you make it inside `Transaction.statements` too.
|
|
136
|
+
- **A publish names the revision it was made from** (`basedOn`, the entry's
|
|
137
|
+
`revision`, or `null`). A newer one is `CmsConflict: ...`, not a publish over
|
|
138
|
+
someone else's. The entry's `revision` is compared and set first, inside the
|
|
139
|
+
transaction, so of two publishes made from one revision one writes nothing.
|
|
140
|
+
- A draft your mutation's Input refuses is not published, and the error says why.
|
|
141
|
+
The draft is kept.
|
|
142
|
+
- A row that is already shown keeps the date it was first published on.
|
|
143
|
+
- What your handler returns (patches, connection changes, deletions) goes to the
|
|
144
|
+
client with the entry, the revision and the row's `published` member.
|
|
145
|
+
|
|
146
|
+
### Slugs
|
|
147
|
+
|
|
148
|
+
A content type with a `slug` role is found by it: `cms.queries` has
|
|
149
|
+
`Cms.bySlug(Posts)`, behind the same `visible` rule as every other read, so a
|
|
150
|
+
visitor does not find what is not published.
|
|
151
|
+
|
|
152
|
+
A publish to a slug another row has is refused as
|
|
153
|
+
`CmsSlugTaken: slug: "hello" is already used`, and `Cms.slugTaken.key(message)`
|
|
154
|
+
is the form key to show it on. **The check is advice; a unique index is the
|
|
155
|
+
rule.** Two publishes can both pass the check, and the one the index refuses
|
|
156
|
+
gets the same error. Without a unique index on the column, that race publishes
|
|
157
|
+
two rows at one address. The slug is read from the publish input's member of the
|
|
158
|
+
same key as the Entity's.
|
|
159
|
+
|
|
160
|
+
### Unpublishing
|
|
161
|
+
|
|
162
|
+
`CmsUnpublish` empties the `published` column. The row and its revisions are
|
|
163
|
+
kept; a visitor stops seeing it, by every path, and publishing shows it again.
|
|
164
|
+
Only a type with a `published` role offers it.
|
|
165
|
+
|
|
166
|
+
## Scheduling
|
|
167
|
+
|
|
168
|
+
`CmsSchedule` promises a draft for later: it sets the draft's `scheduledFor`,
|
|
169
|
+
after checking the publish mutation would take the draft now. What could not be
|
|
170
|
+
published now is not promised for later. `CmsUnschedule` takes the promise back.
|
|
171
|
+
|
|
172
|
+
Nothing else happens until you call `due`:
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
// A Cloudflare cron trigger, a setInterval, a queue consumer: the host's choice.
|
|
176
|
+
export default {
|
|
177
|
+
scheduled: () =>
|
|
178
|
+
run(cms.due(new Date(), { as: name => authors.byName(name) })),
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
- **The package owns no timer.** Hosts keep time differently, and a library that
|
|
183
|
+
starts its own runs twice behind a load balancer.
|
|
184
|
+
- `due(now, { as })` publishes every draft whose time has come, each in its own
|
|
185
|
+
transaction, **as whoever scheduled it**: a draft keeps the name your `nameOf`
|
|
186
|
+
gave, and `as` says who that is now. Someone who has since lost the right
|
|
187
|
+
publishes nothing.
|
|
188
|
+
- **A publish that fails stays scheduled, with the reason.** The entry reads
|
|
189
|
+
overdue with that error, because that is true. It is not tried again until the
|
|
190
|
+
draft changes: a fault the author must fix is not hammered every minute.
|
|
191
|
+
- **What was promised is changed only by someone who could have promised it.**
|
|
192
|
+
Since a scheduled draft is published as whoever scheduled it, saving over it,
|
|
193
|
+
restoring over it or discarding it is refused to an author your `allow` would
|
|
194
|
+
not let `schedule`. Otherwise someone who may not publish would publish through
|
|
195
|
+
someone who may.
|
|
196
|
+
- One failure does not stop the rest. `due` answers with each entry and its
|
|
197
|
+
error, or `null`.
|
|
198
|
+
- An archived entry keeps its promise, and keeps it waiting.
|
|
199
|
+
|
|
200
|
+
## Restoring
|
|
201
|
+
|
|
202
|
+
`CmsRestore` makes a revision's value the working copy. **It publishes nothing**:
|
|
203
|
+
what comes back is a draft like any other, to look at, change, and publish, which
|
|
204
|
+
makes the next revision. It replaces what the draft held, and takes back a
|
|
205
|
+
promise made of that. The saved Model goes with it, so an editor fills its form
|
|
206
|
+
from the values, key by key, and a revision older than the form still opens.
|
|
207
|
+
Only what has been published has a past to restore.
|
|
208
|
+
|
|
209
|
+
## Archiving
|
|
210
|
+
|
|
211
|
+
`CmsArchive` puts an entry away: off the worklist, and, for a type with a
|
|
212
|
+
`published` role, off show. `CmsUnarchive` brings it back as unpublished work;
|
|
213
|
+
publishing shows it again. Put away, an entry offers nothing else.
|
|
214
|
+
|
|
215
|
+
## The worklist, and an entry's state
|
|
216
|
+
|
|
217
|
+
`cms.queries` is `Cms.Entries`: the entries of one content type, searched by
|
|
218
|
+
label (as typed: `%` and `_` are characters, not wildcards), the archived ones or the rest. It lists entries, not content rows, so
|
|
219
|
+
something never published is in it.
|
|
220
|
+
|
|
221
|
+
An entry's `state` is not a column. This server derives it, with `Cms.state`,
|
|
222
|
+
from whether the entry has a draft and whether a visitor can see its row, using
|
|
223
|
+
your `now`. An overdue scheduled publish reads overdue, with its reason.
|
|
224
|
+
|
|
225
|
+
## API
|
|
226
|
+
|
|
227
|
+
| Call | Meaning |
|
|
228
|
+
| --- | --- |
|
|
229
|
+
| `sqliteTables()`, `pgTables()` | The three tables, per dialect. `sqliteSchema` is their `create table` statements. |
|
|
230
|
+
| `published(column, isAuthor)` | A `visible` rule for a content table: a visitor sees rows whose column is set. |
|
|
231
|
+
| `Transaction.statements`, `Transaction.drizzle` | How a publish is made whole, by driver. |
|
|
232
|
+
| `CmsServer.make({ tables, content, transaction, isAuthor, allow?, now?, nameOf?, maxDraftSize? })` | `sources`, `queries`, `mutations`, `due`, and `bindings`. |
|
|
233
|
+
| `cms.due(now, { as })` | Publishes what has come due; an Effect of `{ entry, error }` each. |
|
|
234
|
+
|
|
235
|
+
## Limits
|
|
236
|
+
|
|
237
|
+
- `allow` is asked about every transition.
|
|
238
|
+
- `CmsPublish` needs a draft. To show an unpublished row again as it is, the
|
|
239
|
+
editor's publish saves what is there first; a client of your own does the same.
|
|
240
|
+
- A driver's refusal is recognised by its words (`unique` or `duplicate`, and the
|
|
241
|
+
column's name). SQLite and Postgres say both; a constraint named without the
|
|
242
|
+
column arrives as the driver's own error.
|
|
243
|
+
- Conflicts are refused, not merged.
|
|
244
|
+
- A client that holds an entry's `revisions` list refetches it after a publish;
|
|
245
|
+
the new revision arrives as a patch, not as a place in that list.
|