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 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.