acrylblends 0.7.8 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/dist/index.d.ts +13 -5
  2. package/dist/index.js +72 -13
  3. package/dist/index.js.map +1 -1
  4. package/docs/framework/README.md +2 -2
  5. package/docs/framework/UPSTREAM.md +1 -1
  6. package/docs/framework/getting-started/basic.md +1 -2
  7. package/docs/framework/getting-started/desktop.md +1 -1
  8. package/docs/framework/guides/bring-your-own-tool.md +216 -0
  9. package/docs/framework/guides/create-acrylblends.md +5 -4
  10. package/docs/framework/guides/data.md +2 -1
  11. package/docs/framework/upstream.json +3 -3
  12. package/lib/node-server.js +1 -0
  13. package/package.json +1 -1
  14. package/plugins/acryl-db/plugin.json +6 -1
  15. package/plugins/acryl-db/src/conformance.ts +224 -0
  16. package/plugins/acryl-db/upstream.json +1 -1
  17. package/plugins/acryl-db-sqlite/plugin.json +4 -1
  18. package/plugins/acryl-db-sqlite/upstream.json +1 -1
  19. package/plugins/acryl-door/LICENSE +47 -0
  20. package/plugins/acryl-door/plugin.json +14 -0
  21. package/plugins/acryl-door/src/cli/acryl.ts +207 -0
  22. package/plugins/acryl-door/src/host/api.ts +9 -0
  23. package/plugins/acryl-door/src/host/audit.ts +64 -0
  24. package/plugins/acryl-door/src/host/channel.ts +142 -0
  25. package/plugins/acryl-door/src/host/chat-tools.ts +39 -0
  26. package/plugins/acryl-door/src/host/door-file.ts +94 -0
  27. package/plugins/acryl-door/src/host/door.ts +195 -0
  28. package/plugins/acryl-door/src/host/http.ts +80 -0
  29. package/plugins/acryl-door/src/host/mcp-routes.ts +83 -0
  30. package/plugins/acryl-door/src/host/operator-routes.ts +38 -0
  31. package/plugins/acryl-door/src/host/page-routes.ts +65 -0
  32. package/plugins/acryl-door/src/host/shim.ts +65 -0
  33. package/plugins/acryl-door/src/host/verbs.ts +109 -0
  34. package/plugins/acryl-door/src/index.ts +133 -0
  35. package/plugins/acryl-door/src/page/api.ts +9 -0
  36. package/plugins/acryl-door/src/page/channel.ts +135 -0
  37. package/plugins/acryl-door/src/page/driver/accessibility.ts +163 -0
  38. package/plugins/acryl-door/src/page/driver/actions.ts +186 -0
  39. package/plugins/acryl-door/src/page/driver/driver.ts +153 -0
  40. package/plugins/acryl-door/src/page/driver/ref-table.ts +78 -0
  41. package/plugins/acryl-door/src/page/driver/sensitivity.ts +49 -0
  42. package/plugins/acryl-door/src/page/driver/snapshot.ts +132 -0
  43. package/plugins/acryl-door/src/page/index.ts +40 -0
  44. package/plugins/acryl-door/src/page/indicator.ts +72 -0
  45. package/plugins/acryl-door/src/page/main.ts +7 -0
  46. package/plugins/acryl-door/src/page/protocol.ts +213 -0
  47. package/plugins/acryl-door/src/page/snapshot-text.ts +18 -0
  48. package/plugins/acryl-door/src/page/user-input.ts +24 -0
  49. package/plugins/acryl-door/upstream.json +28 -0
  50. package/plugins/acryl-inspect/upstream.json +1 -1
  51. package/plugins/acryl-kv/plugin.json +6 -1
  52. package/plugins/acryl-kv/src/conformance.ts +226 -0
  53. package/plugins/acryl-kv/upstream.json +1 -1
  54. package/plugins/acryl-kv-db/plugin.json +4 -1
  55. package/plugins/acryl-kv-db/upstream.json +1 -1
  56. package/plugins/acryl-kv-json/plugin.json +4 -1
  57. package/plugins/acryl-kv-json/upstream.json +1 -1
  58. package/plugins/acryl-node-server/src/adapter/listener.ts +5 -0
  59. package/plugins/acryl-node-server/upstream.json +2 -2
  60. package/plugins/acryl-router/src/service.ts +3 -0
  61. package/plugins/acryl-router/upstream.json +2 -2
  62. package/plugins/acryl-router-http/upstream.json +1 -1
  63. package/plugins/acryl-state/upstream.json +1 -1
  64. package/plugins/agent-panel/client/main.ts +159 -0
  65. package/plugins/agent-panel/index.ts +232 -0
  66. package/plugins/agent-panel/plugin.json +21 -0
  67. package/plugins/desktop/upstream.json +1 -1
  68. package/plugins/desktop-web/upstream.json +1 -1
  69. package/plugins/extensions/resolve.ts +16 -2
  70. package/plugins/tsconfig.json +6 -0
  71. package/shared/DEFAULTS.md +11 -6
  72. package/shared/SKILL.md +1 -1
  73. package/templates/agent/blend.yaml +3 -0
  74. package/templates/agent/src/app.ts +18 -2
  75. package/templates/agent/src/routes.ts +14 -1
  76. package/templates/agent/template.json +2 -1
  77. package/templates/agent/test/agent.test.ts +10 -4
  78. package/templates/agent/test/app.test.ts +29 -8
  79. package/templates/agent/test/builder.test.ts +34 -23
  80. package/templates/agent/test/model.test.ts +24 -19
  81. package/templates/blank/src/app.ts +25 -5
  82. package/templates/blank/test/app.test.ts +28 -9
  83. package/templates/chat/AGENTS.md +16 -0
  84. package/templates/chat/blend.yaml +32 -0
  85. package/templates/chat/gitignore +6 -0
  86. package/templates/chat/migrations/001-chat.sql +12 -0
  87. package/templates/chat/package.json +33 -0
  88. package/templates/chat/src/app.ts +107 -0
  89. package/templates/chat/src/chat-app.ts +101 -0
  90. package/templates/chat/src/client/chat.tsx +115 -0
  91. package/templates/chat/src/client/tsconfig.json +26 -0
  92. package/templates/chat/src/db.ts +43 -0
  93. package/templates/chat/src/door.ts +7 -0
  94. package/templates/chat/src/llm.ts +42 -0
  95. package/templates/chat/src/main.ts +28 -0
  96. package/templates/chat/src/page.tsx +40 -0
  97. package/templates/chat/src/routes.ts +15 -0
  98. package/templates/chat/src/runtime.ts +17 -0
  99. package/templates/chat/template.json +12 -0
  100. package/templates/chat/test/chat.test.ts +198 -0
  101. package/templates/chat/test/server.test.ts +78 -0
  102. package/templates/chat/tsconfig.json +20 -0
  103. package/templates/nodejs/AGENTS.md +9 -0
  104. package/templates/nodejs/blend.yaml +29 -0
  105. package/templates/nodejs/gitignore +3 -0
  106. package/templates/nodejs/package.json +30 -0
  107. package/templates/nodejs/src/app.ts +95 -0
  108. package/templates/nodejs/src/client/counter.tsx +12 -0
  109. package/templates/nodejs/src/client/tsconfig.json +26 -0
  110. package/templates/nodejs/src/door.ts +7 -0
  111. package/templates/nodejs/src/main.ts +25 -0
  112. package/templates/nodejs/src/page.tsx +29 -0
  113. package/templates/nodejs/src/routes.ts +40 -0
  114. package/templates/nodejs/template.json +11 -0
  115. package/templates/nodejs/test/app.test.ts +78 -0
  116. package/templates/nodejs/test/server.test.ts +83 -0
  117. package/templates/nodejs/tsconfig.json +20 -0
  118. package/templates/notes/AGENTS.md +26 -0
  119. package/templates/notes/blend.yaml +32 -0
  120. package/templates/notes/gitignore +6 -0
  121. package/templates/notes/migrations/001-notes.sql +18 -0
  122. package/templates/notes/package.json +33 -0
  123. package/templates/notes/src/app.ts +107 -0
  124. package/templates/notes/src/auth.ts +57 -0
  125. package/templates/notes/src/client/counter.tsx +21 -0
  126. package/templates/notes/src/client/tsconfig.json +26 -0
  127. package/templates/notes/src/db.ts +50 -0
  128. package/templates/notes/src/door.ts +7 -0
  129. package/templates/notes/src/main.ts +28 -0
  130. package/templates/notes/src/notes-app.ts +205 -0
  131. package/templates/notes/src/routes.ts +14 -0
  132. package/templates/notes/src/runtime.ts +17 -0
  133. package/templates/notes/src/schema.ts +38 -0
  134. package/templates/notes/src/views.tsx +136 -0
  135. package/templates/notes/template.json +12 -0
  136. package/templates/notes/test/notes.test.ts +280 -0
  137. package/templates/notes/test/server.test.ts +78 -0
  138. package/templates/notes/tsconfig.json +20 -0
  139. package/templates/todo/src/app.ts +27 -7
  140. package/templates/todo/test/todo.test.ts +47 -22
  141. package/templates/todo/upstream.json +1 -1
@@ -0,0 +1,216 @@
1
+ # Bring your own tool: a provider of a port
2
+
3
+ Every choice in an app is a plugin behind a port, and what the app starts with is only a fallback. Any outside npm tool (a database, a store, a renderer, an auth library) joins the
4
+ app as another **provider** of the same port: a plugin that implements the port's interface on top of the tool and provides the service by its name. The code that uses the port
5
+ does not change. The port's **conformance suite** decides whether your provider is accepted, whoever wrote it. And because Cordis finds services by name, the app can swap the
6
+ provider **while it runs**: the plugins that use the port wait, and carry on when the new provider arrives. A person does it, or an agent working in the app folder, through the door.
7
+
8
+ This page does it once, from start to end, with a real tool: [lowdb](https://github.com/typicode/lowdb), a small JSON-file database, becomes a provider of the `kv` port ([Data](data.md#a-tiny-app-kv)).
9
+ Every step runs as written: the code on this page is extracted and run against a fresh app by `scripts/verify-byo-tool.mjs`.
10
+
11
+ You need an app with the `extensions` plugin, which installs plugins while the app runs: `npx acrylblends init my-app --template agent`, then `cd my-app` and `npm install`.
12
+
13
+ ## 1. Bring the port, its suite and a way to run it
14
+
15
+ ```sh
16
+ npx acrylblends add kv --suite
17
+ npm install
18
+ ```
19
+
20
+ `add kv` copies the port (`plugins/acryl-kv`) and the JSON-file provider (`plugins/acryl-kv-json`) and gives your plugins the names `#acryl-kv` and `#acryl-kv-json` (entries in `package.json` "imports").
21
+ `--suite` also copies the port's conformance suite (`#acryl-kv/conformance`) and adds `vitest`, which runs it. The same works for the `db` port: `npx acrylblends add db --suite`.
22
+
23
+ ## 2. Install the tool
24
+
25
+ ```sh
26
+ npm install lowdb
27
+ ```
28
+
29
+ ## 3. Write the provider
30
+
31
+ A provider is a folder in `plugins/` with an `index.ts` that exports `name`, and `apply(ctx)` which provides the service. Keep the tool behind a plain function that returns the port's interface (`openKv`
32
+ here), so tests and plain code use it without Cordis, and let `apply` be a thin wrapper that provides it for as long as the plugin runs.
33
+
34
+ The port gives you the checks every provider must make alike (`checkKey`, `encodeValue`, `decodeValue`, `runChange`, `compareKeys`), so a bad call fails the same way on a file, on SQL and on your tool.
35
+
36
+ ```ts no-check
37
+ // plugins/kv-lowdb/index.ts
38
+ import { join } from 'node:path'
39
+ import type { Context } from '@deepseek-ai/cordis'
40
+ import { checkKey, compareKeys, decodeValue, encodeValue, KV_SERVICE, runChange } from '#acryl-kv'
41
+ import type { Kv, KvOpened } from '#acryl-kv'
42
+ import { Low } from 'lowdb'
43
+ import { JSONFile } from 'lowdb/node'
44
+
45
+ /** The kv port on lowdb: one JSON file, written atomically by lowdb after every change. */
46
+ export async function openKv({ path }: { path: string }): Promise<KvOpened> {
47
+ const db = new Low<Record<string, unknown>>(new JSONFile(path), {})
48
+ await db.read()
49
+ // One call at a time, in order: that is what makes `update` atomic and lets `close` wait for the writes in flight.
50
+ let queue: Promise<unknown> = Promise.resolve()
51
+ let closed = false
52
+ const run = <T>(work: () => Promise<T>): Promise<T> => {
53
+ if (closed) return Promise.reject(new Error('the kv store is closed'))
54
+ const result = queue.then(work)
55
+ queue = result.catch(() => {})
56
+ return result
57
+ }
58
+ // A copy of the stored value, so changing what `get` returned changes nothing stored.
59
+ const copy = (key: string): unknown =>
60
+ key in db.data ? decodeValue(encodeValue(db.data[key])) : undefined
61
+ const kv: Kv = {
62
+ get: <T>(key: string) =>
63
+ run(async () => {
64
+ checkKey(key)
65
+ return copy(key) as T | undefined
66
+ }),
67
+ set: (key, value) =>
68
+ run(async () => {
69
+ checkKey(key)
70
+ db.data[key] = decodeValue(encodeValue(value))
71
+ await db.write()
72
+ }),
73
+ update: <T>(key: string, change: (current: T | undefined) => T) =>
74
+ run(async () => {
75
+ checkKey(key)
76
+ const next = runChange(change, copy(key) as T | undefined)
77
+ db.data[key] = decodeValue(encodeValue(next))
78
+ await db.write()
79
+ return copy(key) as T
80
+ }),
81
+ delete: (key) =>
82
+ run(async () => {
83
+ checkKey(key)
84
+ delete db.data[key]
85
+ await db.write()
86
+ }),
87
+ list: (prefix = '') =>
88
+ run(async () =>
89
+ Object.keys(db.data)
90
+ .filter((key) => key.startsWith(prefix))
91
+ .sort(compareKeys),
92
+ ),
93
+ }
94
+ return {
95
+ kv,
96
+ async close() {
97
+ closed = true
98
+ await queue
99
+ },
100
+ }
101
+ }
102
+
103
+ export const name = 'kv-lowdb'
104
+
105
+ /** As a plugin: provide `kv` for as long as the plugin runs; the store closes after `kv` is withdrawn. */
106
+ export async function apply(ctx: Context, config: { path?: string } = {}): Promise<void> {
107
+ const file = config.path ?? join(ctx.appInstance.home, 'kv-lowdb.json')
108
+ await ctx.effect(async function* () {
109
+ const opened = await openKv({ path: file })
110
+ yield () => opened.close()
111
+ yield ctx.reflect.provide(KV_SERVICE, opened.kv)
112
+ }, `kv-lowdb: ${file}`)
113
+ }
114
+ ```
115
+
116
+ (A block whose first line is a path comment is the file of that name.)
117
+
118
+ ## 4. Pass the suite
119
+
120
+ The suite is the contract: it runs the same cases against every provider. A test gives it a fresh store for each case and a way to reopen the same storage.
121
+
122
+ ```ts no-check
123
+ // test/kv-lowdb.spec.ts
124
+ import { mkdir, mkdtemp } from 'node:fs/promises'
125
+ import { join } from 'node:path'
126
+ import { describeKvConformance, type KvUnderTest } from '#acryl-kv/conformance'
127
+ import { openKv } from '../plugins/kv-lowdb/index.ts'
128
+
129
+ const underTest = async (path: string): Promise<KvUnderTest> => {
130
+ const opened = await openKv({ path })
131
+ return {
132
+ kv: opened.kv,
133
+ close: opened.close,
134
+ reopen: async () => {
135
+ await opened.close()
136
+ return underTest(path)
137
+ },
138
+ }
139
+ }
140
+
141
+ // A fresh file per case, in a folder of the project (a sandboxed agent may not write to the system temp folder).
142
+ describeKvConformance('kv-lowdb', async () => {
143
+ await mkdir('.acrylblends', { recursive: true })
144
+ return underTest(join(await mkdtemp(join('.acrylblends', 'kv-lowdb-')), 'kv.json'))
145
+ })
146
+ ```
147
+
148
+ ```sh
149
+ npx vitest run test/kv-lowdb.spec.ts
150
+ ```
151
+
152
+ All the cases must pass. A failure names the rule that broke (`counts fifty concurrent updates to exactly fifty`, `keeps what was written across close and reopen`, ...): fix the provider, not the test.
153
+
154
+ ## 5. Make the default swappable, and swap
155
+
156
+ A provider that the app's code mounts at start (a row of `blend.yaml`) is fixed until the next start. To swap while the app runs, install both the default and yours as plugins of the app. A one-line plugin
157
+ names the default:
158
+
159
+ ```ts no-check
160
+ // plugins/kv/index.ts
161
+ export { apply, Config } from '#acryl-kv-json'
162
+ export const name = 'kv'
163
+ ```
164
+
165
+ Start the app and open it in a browser (`npm run dev`). The door is the command `.acryl/bin/acryl` in the app folder:
166
+
167
+ ```sh
168
+ .acryl/bin/acryl plugin add plugins/kv
169
+ ```
170
+
171
+ Any plugin that declares `inject: ['kv']` now has a store. This one counts visits:
172
+
173
+ ```ts no-check
174
+ // plugins/visits/index.ts
175
+ import type { Context } from '@deepseek-ai/cordis'
176
+ import { Acryl } from 'acrylblends'
177
+ import type {} from '#acryl-kv'
178
+
179
+ export const name = 'visits'
180
+ export const inject = ['router', 'kv']
181
+
182
+ /** GET /visits counts the visits in the kv store, whichever provider holds it. */
183
+ export function apply(ctx: Context): void {
184
+ ctx.router.route(
185
+ '/',
186
+ new Acryl().get('/visits', async (c) =>
187
+ c.json({ visits: await ctx.kv.update<number>('visits', (n) => (n ?? 0) + 1) }),
188
+ ),
189
+ )
190
+ }
191
+ ```
192
+
193
+ ```sh
194
+ .acryl/bin/acryl plugin add plugins/visits
195
+ curl <the address npm run dev printed>/visits # {"visits":1}, then 2, then 3
196
+ ```
197
+
198
+ Now swap. Two providers of one service cannot run together (the second is refused and nothing changes), so take the default out and put yours in:
199
+
200
+ ```sh
201
+ .acryl/bin/acryl plugin remove kv
202
+ .acryl/bin/acryl plugin list # visits: pending, missing kv
203
+ .acryl/bin/acryl plugin add plugins/kv-lowdb
204
+ curl <the address>/visits # {"visits":1}: served again, by the same process
205
+ ```
206
+
207
+ `visits` was never edited. While no provider existed its route was gone and its state was `pending`; the moment `kv-lowdb` provided `kv` it started again. The process did not restart, and the swap survives a
208
+ restart: the app remembers what was installed.
209
+
210
+ ## What to know
211
+
212
+ - **Data does not move by itself.** The new store starts empty (the visits counted from 1 again). To carry it over, copy before you swap, in a script or a plugin: `copyKv(source, target)` from `#acryl-kv` copies one store into another.
213
+ - **A failed install changes nothing.** A provider that throws on start, or that cannot import something, is refused and the previous state stays. To go back: `plugin remove kv-lowdb`, then `plugin add plugins/kv`.
214
+ - **The suite checks the port, not your tool.** It cannot know that lowdb loses a write on a power cut. Read what the tool promises about durability before you trust it with more than a demo.
215
+ - **Other ports and tools work the same way.** `db`: `npx acrylblends add db --suite`, wrap a driver or an ORM behind `Db`, pass `describeDbConformance`. Keep the opener rule ([Data](data.md#why-the-database-is-opened-in-one-place)): only the file that mounts the provider imports the tool.
216
+ - **An agent can do all of it.** Give a coding agent in the app folder the page and one line: "wrap `<package>` as a provider of the `kv` port, pass its suite, and swap it in through the door". `AGENTS.md` in the app names the door.
@@ -7,13 +7,14 @@
7
7
 
8
8
  | Template | What you get |
9
9
  | --- | --- |
10
- | `nodejs` | an Acryl application served with `serve()` from `acrylblends/node-server`; run with Node.js 22.19 or newer |
10
+ | `nodejs` | a web app: routes, a server-rendered JSX page and a client component, mounted by the app's own plugin; run with Node.js 22.19 or newer |
11
11
  | `notes` | a full-stack starter: accounts (scrypt, cookie session), notes in SQLite through the [`db` port](data.md) with numbered migrations, validated forms, search, a JSON API with a typed test client, csrf, a test for every route, a client-component counter |
12
12
  | `chat` | an AI chat starter: the answer streams token by token from an OpenAI-compatible API (DeepSeek by default, key only from `DEEPSEEK_API_KEY`), a client component with a Stop button that also stops the request to the model, history in SQLite through the [`db` port](data.md), tests with a mock model |
13
- | `bun` (experimental) | an Acryl application with `export default app`, run with `bun` |
14
13
  | `acryl-app` | a Cordis app built from plugins, with an agent that can extend it while it runs (the same app `npx acrylblends init --template agent` creates) |
15
14
 
16
- Every template includes `AGENTS.md` and `CLAUDE.md` (which imports `AGENTS.md`) and a copy of these docs in `docs/framework/`, so a coding agent working in the folder reads the
15
+ Every template is a Cordis app: `blend.yaml` lists the plugins that are mounted, every plugin is a folder in `plugins/`, and the app is started by `src/main.ts`. A bare template name after the directory is the same as `--template`: `npm create acrylblends@latest my-app notes`. `bun` is no longer a template; the framework still runs on Bun (see [Bun](../getting-started/bun.md)), but a new app is a Cordis app on Node.
16
+
17
+ Every template includes `AGENTS.md` and `CLAUDE.md` (the same text), one skill (`app-guide`) in `.claude/skills/` and `.agents/skills/`, and a copy of these docs in `docs/framework/`, so a coding agent working in the folder reads the
17
18
  API from the folder.
18
19
 
19
20
  ## Passing arguments
@@ -50,7 +51,7 @@ bun create acrylblends@latest my-app --template nodejs
50
51
  | :-- | :-- | :-- |
51
52
  | `[directory]` | The folder to create. It must not exist or must be empty. Asked for when it is missing. | `my-app` |
52
53
  | `[source]` | A template from outside the package: `github:owner/repo[#ref]`, `@scope/name[@version]` or a packed `.tgz` file. See [Templates from a repository or a package](#templates-from-a-repository-or-a-package). | `github:acme/starter` |
53
- | `--template <template>` | One of `nodejs`, `notes`, `chat`, `bun` (experimental), `acryl-app`. Skips the template question. | `--template bun` |
54
+ | `--template <template>` | One of `nodejs`, `notes`, `chat`, `acryl-app`. Skips the template question; a bare name after the directory means the same. | `--template notes` |
54
55
  | `--install` | Install the dependencies with the package manager after creating the project. For a template from a source, the install scripts it will run are listed first. | `--install` |
55
56
  | `--pm <packageManager>` | The package manager for `--install`: `npm`, `pnpm`, `yarn` or `bun`. Defaults to the one that started the initializer. | `--pm pnpm` |
56
57
  | `--yes`, `-y` | Ask nothing: use `nodejs` unless `--template` is given, and the directory `acryl-app`. | `-y` |
@@ -10,6 +10,7 @@ interface; it is not part of `acrylblends` or the templates, and an app adds it
10
10
  | The app | Use | State |
11
11
  | --- | --- | --- |
12
12
  | Normal web or desktop app, users, lists, orders | `db` with SQLite (the `notes` and `chat` templates) | available |
13
+ | SQLite is not enough, but no server | `db` with the optional `acryl-db-pglite` plugin: Postgres in the app's process, one folder in the app home | optional, not in `acrylblends` |
13
14
  | Must use Postgres or Supabase | `db` with the optional `acryl-db-postgres` plugin | optional, not in `acrylblends` |
14
15
  | Tiny, one person, a few screens | [`kv`](#a-tiny-app-kv), named JSON documents with no SQL | available |
15
16
 
@@ -190,7 +191,7 @@ await db.transaction(async (tx) => {
190
191
 
191
192
  ## Writing SQL that moves to Postgres
192
193
 
193
- The optional Postgres plugin serves the same interface. SQL that stays inside this list runs on both:
194
+ The optional Postgres plugins (`acryl-db-pglite` embedded, `acryl-db-postgres` on a server) serve the same interface. SQL that stays inside this list runs on both:
194
195
 
195
196
  - Placeholders are `?` on every provider.
196
197
  - Use `RETURNING` to get a new row, not a driver-specific "last id".
@@ -2,8 +2,8 @@
2
2
  "source": {
3
3
  "repo": "acryldev/acryl",
4
4
  "path": "docs/framework",
5
- "commit": "f132283c43337f5e1d58dfbbbc15786efb5e756c"
5
+ "commit": "20bdc263d6f0f8febbd967007bda4cf4a802efd2"
6
6
  },
7
- "files": 99,
8
- "digest": "sha256:9b0fe3329319398ce1108a015a99247c001b02cfb30a953b4b180a73674e2aec"
7
+ "files": 100,
8
+ "digest": "sha256:ab5a055bc0bfb6c47ee6bb70c72ef6ec675ad070d790b58e601cd764591df788"
9
9
  }
@@ -835,6 +835,7 @@ const drainIncoming = (incoming) => {
835
835
  bytesRead += chunk.length;
836
836
  if (bytesRead > MAX_DRAIN_BYTES) forceClose();
837
837
  };
838
+ incoming.removeAllListeners("data");
838
839
  incoming.on("data", onData);
839
840
  incoming.on("end", cleanup);
840
841
  incoming.on("error", cleanup);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acrylblends",
3
- "version": "0.7.8",
3
+ "version": "0.8.1",
4
4
  "description": "The Acryl web framework (import { Acryl } from \"acrylblends\") and the acrylblends command that scaffolds Cordis apps.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -8,5 +8,10 @@
8
8
  "@deepseek-ai/cordis": "4.0.4"
9
9
  },
10
10
  "mount": "it is the port, not a service: a plugin declares inject: ['db'] and imports its types from '../plugins/acryl-db/src/index.ts' (or the ctx.db type from '../plugins/acryl-db/src/cordis.ts'); add acryl-db-sqlite to provide it",
11
- "omit": ["src/conformance.ts", "**/*.test.*", "**/*.case.ts"]
11
+ "omit": ["**/*.test.*", "**/*.case.ts"],
12
+ "imports": {
13
+ "#acryl-db": "acryl-db/src/index.ts",
14
+ "#acryl-db/conformance": "acryl-db/src/conformance.ts"
15
+ },
16
+ "suite": "src/conformance.ts"
12
17
  }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * The `db` conformance suite: what every provider of the port must do, as Vitest tests. A provider passes it to be accepted
3
+ * (`describeDbConformance('acryl-db-sqlite', open)` in its own tests); the SQL is the portable subset, with `?` bindings, so the same
4
+ * suite runs unchanged on SQLite and Postgres.
5
+ *
6
+ * A `?` inside a string literal, a quoted identifier or a comment is text, not a binding. Foreign keys are enforced (`ON DELETE CASCADE` works) and `INSERT ... RETURNING` answers through `get`, on every provider.
7
+ *
8
+ * The transaction cases are the contract the SQL facade documents (and its SQLite tests check): other calls and transactions wait for
9
+ * an active transaction, a rejected callback rolls back and rejects with its own error, and a handle is dead once its transaction settles.
10
+ */
11
+
12
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
13
+ import { tmpdir } from 'node:os'
14
+ import { join } from 'node:path'
15
+ import { afterEach, describe, expect, it } from 'vitest'
16
+ import type { Db, DbExecutor } from './facade/database.ts'
17
+ import { applyMigrations } from './facade/migrations.ts'
18
+ import { readMigrationFiles } from './migration-files.ts'
19
+ import { MIGRATIONS_TABLE } from './index.ts'
20
+
21
+ /** A fresh, empty database from the provider under test, and how to close it. */
22
+ export interface DbUnderTest {
23
+ readonly db: Db
24
+ close(): Promise<void>
25
+ }
26
+
27
+ /** A promise and the function that settles it, for holding a transaction open. */
28
+ function gate(): { readonly promise: Promise<void>, readonly open: () => void } {
29
+ let open!: () => void
30
+ const promise = new Promise<void>(resolve => { open = resolve })
31
+ return { promise, open }
32
+ }
33
+
34
+ export function describeDbConformance(provider: string, open: () => Promise<DbUnderTest>): void {
35
+ describe(`db conformance: ${provider}`, () => {
36
+ const opened: DbUnderTest[] = []
37
+ const fresh = async (): Promise<Db> => {
38
+ const subject = await open()
39
+ opened.push(subject)
40
+ return subject.db
41
+ }
42
+ afterEach(async () => {
43
+ await Promise.all(opened.splice(0).map(subject => subject.close()))
44
+ })
45
+
46
+ it('round-trips null, integers, bigints in the safe range, reals, text and bytes', async () => {
47
+ const db = await fresh()
48
+ await db.exec('CREATE TABLE value_probe (n INTEGER, i INTEGER, b BIGINT, r DOUBLE PRECISION, t TEXT, x BYTEA)')
49
+ const bytes = new Uint8Array([0, 1, 254, 255])
50
+ await db.run('INSERT INTO value_probe (n, i, b, r, t, x) VALUES (?, ?, ?, ?, ?, ?)', null, 42, 9_007_199_254_740_991n, 1.5, 'café ✓', bytes)
51
+ const row = await db.get<{ n: unknown, i: unknown, b: unknown, r: unknown, t: unknown, x: unknown }>('SELECT n, i, b, r, t, x FROM value_probe')
52
+ expect(row).toEqual({ n: null, i: 42, b: 9_007_199_254_740_991, r: 1.5, t: 'café ✓', x: bytes })
53
+ expect(row?.x).toBeInstanceOf(Uint8Array)
54
+ })
55
+
56
+ it('answers get with undefined and all with [] when nothing matches', async () => {
57
+ const db = await fresh()
58
+ await db.exec('CREATE TABLE empty_probe (value INTEGER)')
59
+ expect(await db.get('SELECT value FROM empty_probe WHERE value = ?', 1)).toBeUndefined()
60
+ expect(await db.all('SELECT value FROM empty_probe')).toEqual([])
61
+ })
62
+
63
+ it('reports how many rows run changed', async () => {
64
+ const db = await fresh()
65
+ await db.exec('CREATE TABLE change_probe (value INTEGER)')
66
+ expect(await db.run('INSERT INTO change_probe (value) VALUES (?), (?), (?)', 1, 2, 3)).toEqual({ changes: 3 })
67
+ expect(await db.run('UPDATE change_probe SET value = value + 10 WHERE value < ?', 3)).toEqual({ changes: 2 })
68
+ expect(await db.run('DELETE FROM change_probe WHERE value = ?', 99)).toEqual({ changes: 0 })
69
+ })
70
+
71
+ it('runs several statements in one exec', async () => {
72
+ const db = await fresh()
73
+ await db.exec('CREATE TABLE multi_probe (value INTEGER); INSERT INTO multi_probe (value) VALUES (1); INSERT INTO multi_probe (value) VALUES (2);')
74
+ expect(await db.all('SELECT value FROM multi_probe ORDER BY value')).toEqual([{ value: 1 }, { value: 2 }])
75
+ })
76
+
77
+ it('enforces foreign keys: ON DELETE CASCADE removes the children, and an orphan insert is refused', async () => {
78
+ const db = await fresh()
79
+ await db.exec('CREATE TABLE fk_parent (id INTEGER PRIMARY KEY); CREATE TABLE fk_child (id INTEGER PRIMARY KEY, parent INTEGER NOT NULL REFERENCES fk_parent (id) ON DELETE CASCADE)')
80
+ await db.run('INSERT INTO fk_parent (id) VALUES (?), (?)', 1, 2)
81
+ await db.run('INSERT INTO fk_child (id, parent) VALUES (?, ?), (?, ?), (?, ?)', 10, 1, 11, 1, 20, 2)
82
+ expect(await db.run('DELETE FROM fk_parent WHERE id = ?', 1)).toEqual({ changes: 1 })
83
+ expect(await db.all('SELECT id FROM fk_child ORDER BY id')).toEqual([{ id: 20 }])
84
+ await expect(db.run('INSERT INTO fk_child (id, parent) VALUES (?, ?)', 30, 99)).rejects.toThrow()
85
+ expect(await db.all('SELECT id FROM fk_child ORDER BY id')).toEqual([{ id: 20 }])
86
+ })
87
+
88
+ it('returns the inserted row through get with INSERT ... RETURNING, inside a transaction too', async () => {
89
+ const db = await fresh()
90
+ await db.exec('CREATE TABLE returning_probe (id INTEGER PRIMARY KEY, body TEXT NOT NULL)')
91
+ expect(await db.get('INSERT INTO returning_probe (id, body) VALUES (?, ?) RETURNING id, body', 1, 'first')).toEqual({ id: 1, body: 'first' })
92
+ const second = await db.transaction(tx => tx.get<{ id: number }>('INSERT INTO returning_probe (id, body) VALUES (?, ?) RETURNING id', 2, 'second'))
93
+ expect(second).toEqual({ id: 2 })
94
+ expect(await db.all('SELECT body FROM returning_probe ORDER BY id')).toEqual([{ body: 'first' }, { body: 'second' }])
95
+ })
96
+
97
+ it('binds only the ? outside string literals, quoted identifiers and comments', async () => {
98
+ const db = await fresh()
99
+ expect(await db.get("SELECT '?' AS q, CAST(? AS INTEGER) AS v, 'it''s ?' AS r", 7)).toEqual({ q: '?', v: 7, r: "it's ?" })
100
+ expect(await db.get('SELECT CAST(? AS INTEGER) AS v /* not ? a binding */, CAST(? AS INTEGER) AS w -- nor ?\n', 1, 2)).toEqual({ v: 1, w: 2 })
101
+ await db.exec('CREATE TABLE "odd?name" ("col?" INTEGER, body TEXT)')
102
+ await db.run('INSERT INTO "odd?name" ("col?", body) VALUES (?, \'what?\')', 3)
103
+ expect(await db.all('SELECT "col?" AS c, body FROM "odd?name" WHERE "col?" = ?', 3)).toEqual([{ c: 3, body: 'what?' }])
104
+ })
105
+
106
+ it('commits work done through the transaction handle and resolves the callback value', async () => {
107
+ const db = await fresh()
108
+ const value = await db.transaction(async tx => {
109
+ await tx.exec('CREATE TABLE commit_probe (value INTEGER)')
110
+ await tx.run('INSERT INTO commit_probe (value) VALUES (?)', 1)
111
+ return 'committed'
112
+ })
113
+ expect(value).toBe('committed')
114
+ expect(await db.all('SELECT value FROM commit_probe')).toEqual([{ value: 1 }])
115
+ })
116
+
117
+ it('rolls back when the callback rejects, and rejects with that same error', async () => {
118
+ const db = await fresh()
119
+ await db.exec('CREATE TABLE rollback_probe (value INTEGER)')
120
+ const failure = new Error('roll back')
121
+ const outcome = db.transaction(async tx => {
122
+ await tx.run('INSERT INTO rollback_probe (value) VALUES (?)', 1)
123
+ throw failure
124
+ })
125
+ await expect(outcome).rejects.toBe(failure)
126
+ expect(await db.all('SELECT value FROM rollback_probe')).toEqual([])
127
+ })
128
+
129
+ it('makes other calls and transactions wait for an active transaction, then runs them in call order', async () => {
130
+ const db = await fresh()
131
+ await db.exec('CREATE TABLE queue_probe (value INTEGER)')
132
+ const started = gate()
133
+ const release = gate()
134
+ const first = db.transaction(async tx => {
135
+ await tx.run('INSERT INTO queue_probe (value) VALUES (?)', 1)
136
+ started.open()
137
+ await release.promise
138
+ })
139
+ await started.promise
140
+ const settled: string[] = []
141
+ const write = db.run('INSERT INTO queue_probe (value) VALUES (?)', 2).then(() => { settled.push('write') })
142
+ const second = db.transaction(async tx => { await tx.run('INSERT INTO queue_probe (value) VALUES (?)', 3) }).then(() => { settled.push('second') })
143
+ const read = db.all<{ value: number }>('SELECT value FROM queue_probe ORDER BY value').then(rows => { settled.push('read'); return rows })
144
+ await new Promise(resolve => setTimeout(resolve, 20))
145
+ expect(settled).toEqual([])
146
+ release.open()
147
+ await Promise.all([first, write, second])
148
+ expect(await read).toEqual([{ value: 1 }, { value: 2 }, { value: 3 }])
149
+ expect(settled).toEqual(['write', 'second', 'read'])
150
+ })
151
+
152
+ it('keeps a rolled-back transaction from leaking into calls made while it ran', async () => {
153
+ const db = await fresh()
154
+ await db.exec('CREATE TABLE isolation_probe (value INTEGER)')
155
+ const transaction = db.transaction(async tx => {
156
+ await tx.run('INSERT INTO isolation_probe (value) VALUES (?)', 1)
157
+ await Promise.resolve()
158
+ throw new Error('roll back')
159
+ })
160
+ const before = db.all('SELECT value FROM isolation_probe')
161
+ const write = db.run('INSERT INTO isolation_probe (value) VALUES (?)', 2)
162
+ const after = db.all('SELECT value FROM isolation_probe')
163
+ await expect(transaction).rejects.toThrow('roll back')
164
+ await write
165
+ expect(await before).toEqual([])
166
+ expect(await after).toEqual([{ value: 2 }])
167
+ })
168
+
169
+ it('rejects a transaction handle used after its transaction settles', async () => {
170
+ const db = await fresh()
171
+ await db.exec('CREATE TABLE stale_probe (value INTEGER)')
172
+ let handle!: DbExecutor
173
+ await db.transaction(async tx => {
174
+ handle = tx
175
+ await tx.run('INSERT INTO stale_probe (value) VALUES (?)', 1)
176
+ })
177
+ await expect(handle.run('INSERT INTO stale_probe (value) VALUES (?)', 2)).rejects.toThrow()
178
+ await expect(handle.get('SELECT value FROM stale_probe')).rejects.toThrow()
179
+ expect(await db.all('SELECT value FROM stale_probe')).toEqual([{ value: 1 }])
180
+ })
181
+
182
+ it('applies migrations once and in order, and refuses a schema newer than the code', async () => {
183
+ const db = await fresh()
184
+ const first = [{ version: 1, statements: ['CREATE TABLE notes (body TEXT)', "INSERT INTO notes (body) VALUES ('kept')"] }]
185
+ await applyMigrations(db, first, { table: MIGRATIONS_TABLE })
186
+ await applyMigrations(db, first, { table: MIGRATIONS_TABLE })
187
+ await applyMigrations(db, [...first, { version: 2, statements: ['ALTER TABLE notes ADD COLUMN title TEXT'] }], { table: MIGRATIONS_TABLE })
188
+ expect(await db.all('SELECT body, title FROM notes')).toEqual([{ body: 'kept', title: null }])
189
+ expect(await db.get(`SELECT version FROM ${MIGRATIONS_TABLE}`)).toEqual({ version: 2 })
190
+ await expect(applyMigrations(db, first, { table: MIGRATIONS_TABLE })).rejects.toThrow('Schema version 2 is newer than supported version 1')
191
+ })
192
+
193
+ it('rolls every pending migration back together when one fails', async () => {
194
+ const db = await fresh()
195
+ const failing = [
196
+ { version: 1, statements: ['CREATE TABLE migration_first (value TEXT)', "INSERT INTO migration_first (value) VALUES ('retained')"] },
197
+ { version: 2, statements: ['CREATE TABLE migration_second (value TEXT)', 'THIS IS NOT SQL'] },
198
+ ]
199
+ await expect(applyMigrations(db, failing, { table: MIGRATIONS_TABLE })).rejects.toThrow()
200
+ await expect(db.all('SELECT value FROM migration_first')).rejects.toThrow()
201
+ // A provider may create the version table when it opens; then it still says 0.
202
+ const version = await db.get<{ version: unknown }>(`SELECT version FROM ${MIGRATIONS_TABLE}`).catch(() => undefined)
203
+ expect(version === undefined || Number(version.version) === 0).toBe(true)
204
+ await applyMigrations(db, [failing[0]!, { version: 2, statements: ['CREATE TABLE migration_second (value TEXT)'] }], { table: MIGRATIONS_TABLE })
205
+ expect(await db.get(`SELECT version FROM ${MIGRATIONS_TABLE}`)).toEqual({ version: 2 })
206
+ expect(await db.all('SELECT value FROM migration_first')).toEqual([{ value: 'retained' }])
207
+ })
208
+
209
+ it('applies an app\'s numbered migration files', async () => {
210
+ const db = await fresh()
211
+ const folder = await mkdtemp(join(tmpdir(), 'acryl-db-migrations-'))
212
+ try {
213
+ await writeFile(join(folder, '001-notes.sql'), 'CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT NOT NULL);\nINSERT INTO notes (id, body) VALUES (1, \'first\');\n')
214
+ await writeFile(join(folder, '002-tags.sql'), 'CREATE TABLE tags (note INTEGER NOT NULL, tag TEXT NOT NULL);\n')
215
+ await applyMigrations(db, await readMigrationFiles(folder), { table: MIGRATIONS_TABLE })
216
+ await db.run('INSERT INTO tags (note, tag) VALUES (?, ?)', 1, 'todo')
217
+ expect(await db.all('SELECT body, tag FROM notes JOIN tags ON tags.note = notes.id')).toEqual([{ body: 'first', tag: 'todo' }])
218
+ expect(await db.get(`SELECT version FROM ${MIGRATIONS_TABLE}`)).toEqual({ version: 2 })
219
+ } finally {
220
+ await rm(folder, { recursive: true, force: true })
221
+ }
222
+ })
223
+ })
224
+ }
@@ -2,7 +2,7 @@
2
2
  "source": {
3
3
  "repo": "acryldev/acryl",
4
4
  "path": "plugins/acryl-db",
5
- "commit": "f132283c43337f5e1d58dfbbbc15786efb5e756c"
5
+ "commit": "20bdc263d6f0f8febbd967007bda4cf4a802efd2"
6
6
  },
7
7
  "vendored": [
8
8
  "src",
@@ -9,5 +9,8 @@
9
9
  "@deepseek-ai/schemastery": "3.18.4"
10
10
  },
11
11
  "mount": "mount it after instance: root.plugin(dbSqlite, { migrations: 'migrations' }) with the migrations/ folder (001-name.sql, ...) next to src/; then a plugin that declares inject: ['db'] calls await ctx.db.all('SELECT ...', value)",
12
- "omit": ["**/*.test.*", "**/*.spec.*", "**/*.case.ts"]
12
+ "omit": ["**/*.test.*", "**/*.spec.*", "**/*.case.ts"],
13
+ "imports": {
14
+ "#acryl-db-sqlite": "acryl-db-sqlite/src/index.ts"
15
+ }
13
16
  }
@@ -2,7 +2,7 @@
2
2
  "source": {
3
3
  "repo": "acryldev/acryl",
4
4
  "path": "plugins/acryl-db-sqlite",
5
- "commit": "f132283c43337f5e1d58dfbbbc15786efb5e756c"
5
+ "commit": "20bdc263d6f0f8febbd967007bda4cf4a802efd2"
6
6
  },
7
7
  "vendored": [
8
8
  "src",
@@ -0,0 +1,47 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 WebBoxes s.r.o.
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.
22
+
23
+ ---
24
+
25
+ The files under src/facade are derived from the SQLite facade of chord in pi (https://github.com/earendil-works/pi, packages/chord, commit d5b3de22d0e24a3e9c38a9410d3329b099717283), used under this license:
26
+
27
+ MIT License
28
+
29
+ Copyright (c) 2025 Mario Zechner
30
+
31
+ Permission is hereby granted, free of charge, to any person obtaining a copy
32
+ of this software and associated documentation files (the "Software"), to deal
33
+ in the Software without restriction, including without limitation the rights
34
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
35
+ copies of the Software, and to permit persons to whom the Software is
36
+ furnished to do so, subject to the following conditions:
37
+
38
+ The above copyright notice and this permission notice shall be included in all
39
+ copies or substantial portions of the Software.
40
+
41
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
42
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
43
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
44
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
45
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
46
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
47
+ SOFTWARE.
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "acryl-door",
3
+ "description": "The door of the app: an agent working in the app folder looks at the running page, acts on it (click, type, select, press, scroll, wait) and adds, removes or reloads plugins live, through .acryl/bin/acryl. Loopback only, a per-run secret in .acryl/door.json, an audit log and an in-page indicator with a stop button. Vendored from ACRYL.",
4
+ "requires": ["acryl-router", "acryl-node-server", "instance"],
5
+ "inject": ["router", "appInstance", "nodeServer"],
6
+ "provides": ["door"],
7
+ "dependencies": {
8
+ "@deepseek-ai/cordis": "4.0.4",
9
+ "@deepseek-ai/schemastery": "3.18.4"
10
+ },
11
+ "mount": "mount it after acryl-node-server and extensions in development builds: root.plugin(door); add <script type=\"module\" src=\"/api/acryl-door/page.js\"></script> to the pages; the command is .acryl/bin/acryl",
12
+ "gitignore": [".acryl/"],
13
+ "omit": ["**/*.test.*", "**/*.spec.*", "**/*.case.ts"]
14
+ }