acrylblends 0.7.7 → 0.8.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.
Files changed (195) hide show
  1. package/dist/index.d.ts +14 -6
  2. package/dist/index.js +130 -31
  3. package/dist/index.js.map +1 -1
  4. package/docs/framework/README.md +3 -3
  5. package/docs/framework/UPSTREAM.md +1 -1
  6. package/docs/framework/agents/index.md +18 -3
  7. package/docs/framework/getting-started/basic.md +1 -2
  8. package/docs/framework/getting-started/desktop.md +3 -3
  9. package/docs/framework/guides/bring-your-own-tool.md +216 -0
  10. package/docs/framework/guides/client-modules.md +4 -3
  11. package/docs/framework/guides/create-acrylblends.md +5 -4
  12. package/docs/framework/guides/data.md +7 -6
  13. package/docs/framework/helpers/streaming.md +12 -27
  14. package/docs/framework/upstream.json +3 -3
  15. package/lib/browser/read-sse.js +84 -0
  16. package/lib/db/sqlite.d.ts +1 -1
  17. package/lib/db.d.ts +1 -84
  18. package/lib/index-BLTYsyc1.d.ts +93 -0
  19. package/lib/index-D4FAsBZu.d.ts +85 -0
  20. package/lib/kv/db.d.ts +2 -2
  21. package/lib/kv/json.d.ts +1 -1
  22. package/lib/kv/json.js +87 -24
  23. package/lib/kv.d.ts +1 -92
  24. package/lib/node-server.d.ts +3 -3
  25. package/lib/node-server.js +14 -8
  26. package/lib/read-sse.d.ts +33 -0
  27. package/lib/read-sse.js +84 -0
  28. package/package.json +5 -1
  29. package/plugins/acryl-db/plugin.json +7 -2
  30. package/plugins/acryl-db/src/conformance.ts +224 -0
  31. package/plugins/acryl-db/upstream.json +2 -2
  32. package/plugins/acryl-db-sqlite/plugin.json +4 -1
  33. package/plugins/acryl-db-sqlite/upstream.json +1 -1
  34. package/plugins/acryl-door/LICENSE +47 -0
  35. package/plugins/acryl-door/plugin.json +14 -0
  36. package/plugins/acryl-door/src/cli/acryl.ts +207 -0
  37. package/plugins/acryl-door/src/host/api.ts +9 -0
  38. package/plugins/acryl-door/src/host/audit.ts +64 -0
  39. package/plugins/acryl-door/src/host/channel.ts +142 -0
  40. package/plugins/acryl-door/src/host/chat-tools.ts +39 -0
  41. package/plugins/acryl-door/src/host/door-file.ts +94 -0
  42. package/plugins/acryl-door/src/host/door.ts +195 -0
  43. package/plugins/acryl-door/src/host/http.ts +80 -0
  44. package/plugins/acryl-door/src/host/mcp-routes.ts +83 -0
  45. package/plugins/acryl-door/src/host/operator-routes.ts +38 -0
  46. package/plugins/acryl-door/src/host/page-routes.ts +65 -0
  47. package/plugins/acryl-door/src/host/shim.ts +65 -0
  48. package/plugins/acryl-door/src/host/verbs.ts +109 -0
  49. package/plugins/acryl-door/src/index.ts +133 -0
  50. package/plugins/acryl-door/src/page/api.ts +9 -0
  51. package/plugins/acryl-door/src/page/channel.ts +135 -0
  52. package/plugins/acryl-door/src/page/driver/accessibility.ts +163 -0
  53. package/plugins/acryl-door/src/page/driver/actions.ts +186 -0
  54. package/plugins/acryl-door/src/page/driver/driver.ts +153 -0
  55. package/plugins/acryl-door/src/page/driver/ref-table.ts +78 -0
  56. package/plugins/acryl-door/src/page/driver/sensitivity.ts +49 -0
  57. package/plugins/acryl-door/src/page/driver/snapshot.ts +132 -0
  58. package/plugins/acryl-door/src/page/index.ts +40 -0
  59. package/plugins/acryl-door/src/page/indicator.ts +72 -0
  60. package/plugins/acryl-door/src/page/main.ts +7 -0
  61. package/plugins/acryl-door/src/page/protocol.ts +213 -0
  62. package/plugins/acryl-door/src/page/snapshot-text.ts +18 -0
  63. package/plugins/acryl-door/src/page/user-input.ts +24 -0
  64. package/plugins/acryl-door/upstream.json +28 -0
  65. package/plugins/acryl-inspect/upstream.json +1 -1
  66. package/plugins/acryl-kv/plugin.json +7 -2
  67. package/plugins/acryl-kv/src/conformance.ts +226 -0
  68. package/plugins/acryl-kv/upstream.json +1 -1
  69. package/plugins/acryl-kv-db/plugin.json +4 -1
  70. package/plugins/acryl-kv-db/upstream.json +1 -1
  71. package/plugins/acryl-kv-json/plugin.json +4 -1
  72. package/plugins/acryl-kv-json/src/open.ts +86 -22
  73. package/plugins/acryl-kv-json/upstream.json +2 -2
  74. package/plugins/acryl-node-server/src/adapter/listener.ts +5 -0
  75. package/plugins/acryl-node-server/src/client-modules.ts +10 -9
  76. package/plugins/acryl-node-server/upstream.json +2 -2
  77. package/plugins/acryl-router/src/browser/read-sse.ts +3 -0
  78. package/plugins/acryl-router/src/helpers/read-sse.ts +111 -0
  79. package/plugins/acryl-router/src/service.ts +3 -0
  80. package/plugins/acryl-router/upstream.json +2 -2
  81. package/plugins/acryl-router-http/upstream.json +1 -1
  82. package/plugins/acryl-state/upstream.json +1 -1
  83. package/plugins/agent-panel/client/main.ts +159 -0
  84. package/plugins/agent-panel/index.ts +232 -0
  85. package/plugins/agent-panel/plugin.json +21 -0
  86. package/plugins/builder/index.ts +2 -2
  87. package/plugins/builder/plugin.json +1 -1
  88. package/plugins/desktop/main.ts +3 -3
  89. package/plugins/desktop/plugin.json +2 -2
  90. package/plugins/desktop/upstream.json +1 -1
  91. package/plugins/desktop-web/main.ts +2 -2
  92. package/plugins/desktop-web/plugin.json +2 -2
  93. package/plugins/desktop-web/upstream.json +1 -1
  94. package/plugins/extensions/index.ts +19 -1
  95. package/plugins/extensions/plugin.json +1 -1
  96. package/plugins/extensions/resolve.ts +17 -3
  97. package/plugins/manifest/lock.ts +7 -3
  98. package/plugins/manifest/upgrade-cli.ts +1 -1
  99. package/plugins/save/cli.ts +1 -1
  100. package/plugins/save/plugin.json +1 -1
  101. package/plugins/tsconfig.json +6 -0
  102. package/shared/AGENTS.core.md +10 -10
  103. package/shared/DEFAULTS.md +45 -0
  104. package/shared/SKILL.md +14 -0
  105. package/templates/agent/AGENTS.md +1 -1
  106. package/templates/agent/README.md +6 -6
  107. package/templates/agent/blend.yaml +3 -0
  108. package/templates/agent/gitignore +1 -0
  109. package/templates/agent/package.json +8 -8
  110. package/templates/agent/src/app.ts +46 -17
  111. package/templates/agent/src/main.ts +2 -2
  112. package/templates/agent/src/routes.ts +19 -6
  113. package/templates/agent/template.json +2 -1
  114. package/templates/agent/test/agent.test.ts +11 -5
  115. package/templates/agent/test/app.test.ts +30 -9
  116. package/templates/agent/test/builder.test.ts +35 -24
  117. package/templates/agent/test/model.test.ts +25 -20
  118. package/templates/agent/tsconfig.json +48 -50
  119. package/templates/blank/AGENTS.md +1 -1
  120. package/templates/blank/README.md +2 -2
  121. package/templates/blank/gitignore +1 -0
  122. package/templates/blank/package.json +8 -8
  123. package/templates/blank/src/about.tsx +2 -2
  124. package/templates/blank/src/app.ts +29 -9
  125. package/templates/blank/src/main.ts +1 -1
  126. package/templates/blank/src/routes.ts +1 -1
  127. package/templates/blank/test/app.test.ts +29 -10
  128. package/templates/blank/tsconfig.json +1 -1
  129. package/templates/chat/AGENTS.md +16 -0
  130. package/templates/chat/blend.yaml +29 -0
  131. package/templates/chat/gitignore +6 -0
  132. package/templates/chat/migrations/001-chat.sql +12 -0
  133. package/templates/chat/package.json +33 -0
  134. package/templates/chat/src/app.ts +106 -0
  135. package/templates/chat/src/chat-app.ts +101 -0
  136. package/templates/chat/src/client/chat.tsx +115 -0
  137. package/templates/chat/src/client/tsconfig.json +26 -0
  138. package/templates/chat/src/db.ts +43 -0
  139. package/templates/chat/src/door.ts +7 -0
  140. package/templates/chat/src/llm.ts +42 -0
  141. package/templates/chat/src/main.ts +28 -0
  142. package/templates/chat/src/page.tsx +40 -0
  143. package/templates/chat/src/routes.ts +15 -0
  144. package/templates/chat/src/runtime.ts +17 -0
  145. package/templates/chat/template.json +11 -0
  146. package/templates/chat/test/chat.test.ts +198 -0
  147. package/templates/chat/test/server.test.ts +78 -0
  148. package/templates/chat/tsconfig.json +20 -0
  149. package/templates/nodejs/AGENTS.md +9 -0
  150. package/templates/nodejs/blend.yaml +26 -0
  151. package/templates/nodejs/gitignore +3 -0
  152. package/templates/nodejs/package.json +30 -0
  153. package/templates/nodejs/src/app.ts +92 -0
  154. package/templates/nodejs/src/client/counter.tsx +12 -0
  155. package/templates/nodejs/src/client/tsconfig.json +26 -0
  156. package/templates/nodejs/src/door.ts +7 -0
  157. package/templates/nodejs/src/main.ts +25 -0
  158. package/templates/nodejs/src/page.tsx +29 -0
  159. package/templates/nodejs/src/routes.ts +40 -0
  160. package/templates/nodejs/template.json +3 -0
  161. package/templates/nodejs/test/app.test.ts +78 -0
  162. package/templates/nodejs/test/server.test.ts +83 -0
  163. package/templates/nodejs/tsconfig.json +20 -0
  164. package/templates/notes/AGENTS.md +26 -0
  165. package/templates/notes/blend.yaml +29 -0
  166. package/templates/notes/gitignore +6 -0
  167. package/templates/notes/migrations/001-notes.sql +18 -0
  168. package/templates/notes/package.json +33 -0
  169. package/templates/notes/src/app.ts +106 -0
  170. package/templates/notes/src/auth.ts +57 -0
  171. package/templates/notes/src/client/counter.tsx +21 -0
  172. package/templates/notes/src/client/tsconfig.json +26 -0
  173. package/templates/notes/src/db.ts +50 -0
  174. package/templates/notes/src/door.ts +7 -0
  175. package/templates/notes/src/main.ts +28 -0
  176. package/templates/notes/src/notes-app.ts +205 -0
  177. package/templates/notes/src/routes.ts +14 -0
  178. package/templates/notes/src/runtime.ts +17 -0
  179. package/templates/notes/src/schema.ts +38 -0
  180. package/templates/notes/src/views.tsx +136 -0
  181. package/templates/notes/template.json +11 -0
  182. package/templates/notes/test/notes.test.ts +280 -0
  183. package/templates/notes/test/server.test.ts +78 -0
  184. package/templates/notes/tsconfig.json +20 -0
  185. package/templates/todo/README.md +1 -1
  186. package/templates/todo/gitignore +1 -0
  187. package/templates/todo/package.json +8 -8
  188. package/templates/todo/src/app.ts +32 -12
  189. package/templates/todo/src/main.ts +1 -1
  190. package/templates/todo/src/todo.ts +4 -4
  191. package/templates/todo/test/todo.test.ts +48 -23
  192. package/templates/todo/tsconfig.json +1 -1
  193. package/templates/todo/upstream.json +2 -2
  194. package/lib/database-D_UN6We8.d.ts +0 -44
  195. package/lib/kv-DWDm0Lja.d.ts +0 -39
@@ -0,0 +1,226 @@
1
+ /**
2
+ * The `kv` conformance suite: what every provider of the port must do, as Vitest tests. A provider passes it to be accepted
3
+ * (`describeKvConformance('acryl-kv-json', open)` in its own tests), so the same cases run on a file and on SQL.
4
+ */
5
+
6
+ import { afterEach, describe, expect, it } from 'vitest'
7
+ import type { Kv } from './kv.ts'
8
+
9
+ /** A fresh, empty store from the provider under test. */
10
+ export interface KvUnderTest {
11
+ readonly kv: Kv
12
+ close(): Promise<void>
13
+ /** Close this store (if it is still open) and open the same storage again, to prove what was written is durable. */
14
+ reopen(): Promise<KvUnderTest>
15
+ }
16
+
17
+ export function describeKvConformance(provider: string, open: () => Promise<KvUnderTest>): void {
18
+ describe(`kv conformance: ${provider}`, () => {
19
+ const opened: KvUnderTest[] = []
20
+ const track = (subject: KvUnderTest): KvUnderTest => {
21
+ opened.push(subject)
22
+ return subject
23
+ }
24
+ const fresh = async (): Promise<Kv> => track(await open()).kv
25
+ afterEach(async () => {
26
+ await Promise.all(opened.splice(0).map(subject => subject.close()))
27
+ })
28
+
29
+ it('round-trips null, booleans, numbers, text with unicode, arrays and nested objects', async () => {
30
+ const kv = await fresh()
31
+ const values: unknown[] = [null, true, false, 0, -1.5, 9_007_199_254_740_991, '', 'café ✓ 😀', [], [1, [2, [3]]], {}, { a: { b: [1, { c: null }] }, 'ключ': 'значение' }]
32
+ for (const [index, value] of values.entries()) {
33
+ await kv.set(`value/${index}`, value)
34
+ expect(await kv.get(`value/${index}`), JSON.stringify(value)).toEqual(value)
35
+ }
36
+ })
37
+
38
+ it('answers get with undefined for a missing key, and overwrites on set', async () => {
39
+ const kv = await fresh()
40
+ expect(await kv.get('missing')).toBeUndefined()
41
+ await kv.set('k', { n: 1 })
42
+ await kv.set('k', { n: 2 })
43
+ expect(await kv.get('k')).toEqual({ n: 2 })
44
+ })
45
+
46
+ it('deletes a key, and a missing key is not an error', async () => {
47
+ const kv = await fresh()
48
+ await kv.set('k', 1)
49
+ await kv.delete('k')
50
+ expect(await kv.get('k')).toBeUndefined()
51
+ await expect(kv.delete('k')).resolves.toBeUndefined()
52
+ await expect(kv.delete('never-existed')).resolves.toBeUndefined()
53
+ })
54
+
55
+ it('stores a null value as null, apart from a missing key', async () => {
56
+ const kv = await fresh()
57
+ await kv.set('k', null)
58
+ expect(await kv.get('k')).toBeNull()
59
+ expect(await kv.list()).toEqual(['k'])
60
+ })
61
+
62
+ it('lists keys sorted by code point, with an optional prefix', async () => {
63
+ const kv = await fresh()
64
+ for (const key of ['b', 'a/2', 'a/10', 'a/1', 'ab', 'B', 'a']) await kv.set(key, key)
65
+ expect(await kv.list()).toEqual(['B', 'a', 'a/1', 'a/10', 'a/2', 'ab', 'b'])
66
+ expect(await kv.list('a/')).toEqual(['a/1', 'a/10', 'a/2'])
67
+ expect(await kv.list('a')).toEqual(['a', 'a/1', 'a/10', 'a/2', 'ab'])
68
+ expect(await kv.list('')).toEqual(['B', 'a', 'a/1', 'a/10', 'a/2', 'ab', 'b'])
69
+ expect(await kv.list('nothing')).toEqual([])
70
+ expect(await kv.list(undefined)).toEqual(await kv.list())
71
+ })
72
+
73
+ it('orders astral characters after the rest of the basic plane, as code points', async () => {
74
+ const kv = await fresh()
75
+ for (const key of ['\u{1F600}', '~', 'z', '']) await kv.set(key, 1)
76
+ expect(await kv.list()).toEqual(['z', '', '~', '\u{1F600}'])
77
+ })
78
+
79
+ it('treats % _ \\ and letter case in a prefix as plain characters', async () => {
80
+ const kv = await fresh()
81
+ for (const key of ['100%', '100x', 'a_b', 'axb', 'Abc', 'abc', 'back\\slash', 'backXslash']) await kv.set(key, 1)
82
+ expect(await kv.list('100%')).toEqual(['100%'])
83
+ expect(await kv.list('a_')).toEqual(['a_b'])
84
+ expect(await kv.list('ab')).toEqual(['abc'])
85
+ expect(await kv.list('Ab')).toEqual(['Abc'])
86
+ expect(await kv.list('back\\')).toEqual(['back\\slash'])
87
+ expect(await kv.list('\u{1F600}')).toEqual([])
88
+ })
89
+
90
+ it('updates a missing key from undefined, and a present one from its value, resolving the new value', async () => {
91
+ const kv = await fresh()
92
+ expect(await kv.update<number>('n', current => (current ?? 0) + 1)).toBe(1)
93
+ expect(await kv.update<number>('n', current => (current ?? 0) + 10)).toBe(11)
94
+ expect(await kv.get('n')).toBe(11)
95
+ expect(await kv.update<string[]>('list', current => [...(current ?? []), 'a'])).toEqual(['a'])
96
+ })
97
+
98
+ it('counts fifty concurrent updates to exactly fifty', async () => {
99
+ const kv = await fresh()
100
+ await Promise.all(Array.from({ length: 50 }, () => kv.update<number>('counter', current => (current ?? 0) + 1)))
101
+ expect(await kv.get('counter')).toBe(50)
102
+ })
103
+
104
+ it('keeps updates of different keys independent, and a set between two updates in order', async () => {
105
+ const kv = await fresh()
106
+ const results = await Promise.all([
107
+ kv.update<number>('a', current => (current ?? 0) + 1),
108
+ kv.set('a', 100),
109
+ kv.update<number>('a', current => (current ?? 0) + 1),
110
+ kv.update<number>('b', current => (current ?? 0) + 5),
111
+ ])
112
+ expect(results[0]).toBe(1)
113
+ expect(results[2]).toBe(101)
114
+ expect(await kv.get('a')).toBe(101)
115
+ expect(await kv.get('b')).toBe(5)
116
+ })
117
+
118
+ it('leaves the value as it was when the update callback throws, and rejects with its error', async () => {
119
+ const kv = await fresh()
120
+ await kv.set('k', 'before')
121
+ const failure = new Error('stop')
122
+ await expect(kv.update('k', () => { throw failure })).rejects.toBe(failure)
123
+ expect(await kv.get('k')).toBe('before')
124
+ await expect(kv.update('absent', () => { throw failure })).rejects.toBe(failure)
125
+ expect(await kv.get('absent')).toBeUndefined()
126
+ })
127
+
128
+ it('leaves the value as it was when the update returns something that is not JSON', async () => {
129
+ const kv = await fresh()
130
+ await kv.set('k', 'before')
131
+ await expect(kv.update('k', () => undefined as never)).rejects.toThrow(TypeError)
132
+ await expect(kv.update('k', () => Number.NaN as never)).rejects.toThrow(TypeError)
133
+ expect(await kv.get('k')).toBe('before')
134
+ })
135
+
136
+ it('rejects an update callback that returns a promise, with a message that says what to do, and keeps the value', async () => {
137
+ const kv = await fresh()
138
+ await kv.set('k', 1)
139
+ const asynchronous = (async () => 2) as unknown as (current: number | undefined) => number
140
+ await expect(kv.update<number>('k', asynchronous)).rejects.toThrow(/synchronous/u)
141
+ expect(await kv.get('k')).toBe(1)
142
+ await kv.update<number>('k', current => (current ?? 0) + 1)
143
+ expect(await kv.get('k')).toBe(2)
144
+ })
145
+
146
+ it('refuses an empty or non-string key on every call, and writes nothing', async () => {
147
+ const kv = await fresh()
148
+ for (const key of ['', 1, null, undefined, {}] as unknown as string[]) {
149
+ await expect(kv.get(key)).rejects.toThrow(TypeError)
150
+ await expect(kv.set(key, 1)).rejects.toThrow(TypeError)
151
+ await expect(kv.update(key, () => 1)).rejects.toThrow(TypeError)
152
+ await expect(kv.delete(key)).rejects.toThrow(TypeError)
153
+ }
154
+ expect(await kv.list()).toEqual([])
155
+ })
156
+
157
+ it('refuses a value that is not JSON with a TypeError, and writes nothing', async () => {
158
+ const kv = await fresh()
159
+ const circular: Record<string, unknown> = {}
160
+ circular.self = circular
161
+ for (const value of [undefined, () => 1, Symbol('s'), 1n, Number.NaN, Number.POSITIVE_INFINITY, circular, { deep: [Number.NEGATIVE_INFINITY] }]) {
162
+ await expect(kv.set('k', value), String(value)).rejects.toThrow(TypeError)
163
+ }
164
+ expect(await kv.list()).toEqual([])
165
+ await kv.set('k', 'kept')
166
+ await expect(kv.set('k', undefined)).rejects.toThrow(TypeError)
167
+ expect(await kv.get('k')).toBe('kept')
168
+ })
169
+
170
+ it('hands out copies: changing what get returned, or the value after set, changes nothing stored', async () => {
171
+ const kv = await fresh()
172
+ const original = { list: [1, 2], nested: { n: 1 } }
173
+ await kv.set('k', original)
174
+ original.list.push(3)
175
+ const first = await kv.get<typeof original>('k')
176
+ expect(first).toEqual({ list: [1, 2], nested: { n: 1 } })
177
+ first!.nested.n = 99
178
+ expect(await kv.get('k')).toEqual({ list: [1, 2], nested: { n: 1 } })
179
+ const updated = await kv.update<typeof original>('k', current => {
180
+ current!.list.push(4)
181
+ return current!
182
+ })
183
+ updated.list.push(5)
184
+ expect(await kv.get('k')).toEqual({ list: [1, 2, 4], nested: { n: 1 } })
185
+ })
186
+
187
+ it('stores a value of a megabyte', async () => {
188
+ const kv = await fresh()
189
+ const big = 'x'.repeat(1024 * 1024)
190
+ await kv.set('big', { big })
191
+ expect((await kv.get<{ big: string }>('big'))?.big.length).toBe(big.length)
192
+ })
193
+
194
+ it('keeps what was written across close and reopen, and keeps a deleted key deleted', async () => {
195
+ const first = track(await open())
196
+ await first.kv.set('a', { n: 1 })
197
+ await first.kv.set('b', 'two')
198
+ await first.kv.update<number>('count', current => (current ?? 0) + 1)
199
+ await first.kv.set('gone', 1)
200
+ await first.kv.delete('gone')
201
+ const second = track(await first.reopen())
202
+ expect(await second.kv.list()).toEqual(['a', 'b', 'count'])
203
+ expect(await second.kv.get('a')).toEqual({ n: 1 })
204
+ expect(await second.kv.get('count')).toBe(1)
205
+ expect(await second.kv.get('gone')).toBeUndefined()
206
+ })
207
+
208
+ it('rejects calls after close, and closing twice does nothing', async () => {
209
+ const subject = track(await open())
210
+ await subject.kv.set('k', 1)
211
+ await subject.close()
212
+ await subject.close()
213
+ await expect(subject.kv.get('k')).rejects.toThrow()
214
+ await expect(subject.kv.set('k', 2)).rejects.toThrow()
215
+ })
216
+
217
+ it('waits for writes still in flight when it closes', async () => {
218
+ const subject = track(await open())
219
+ const writes = Array.from({ length: 20 }, (_, index) => subject.kv.set(`k${String(index).padStart(2, '0')}`, index))
220
+ await subject.close()
221
+ await Promise.all(writes)
222
+ const again = track(await subject.reopen())
223
+ expect(await again.kv.list()).toHaveLength(20)
224
+ })
225
+ })
226
+ }
@@ -2,7 +2,7 @@
2
2
  "source": {
3
3
  "repo": "acryldev/acryl",
4
4
  "path": "plugins/acryl-kv",
5
- "commit": "f8e385e4389fe2fdc6fd2a4366a7f2a7389faacb"
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 acryl-db-sqlite: root.plugin(kvDb, {}); it waits for db and provides the same kv as acryl-kv-json",
12
- "omit": ["**/*.test.*", "**/*.spec.*", "**/*.case.ts"]
12
+ "omit": ["**/*.test.*", "**/*.spec.*", "**/*.case.ts"],
13
+ "imports": {
14
+ "#acryl-kv-db": "acryl-kv-db/src/index.ts"
15
+ }
13
16
  }
@@ -2,7 +2,7 @@
2
2
  "source": {
3
3
  "repo": "acryldev/acryl",
4
4
  "path": "plugins/acryl-kv-db",
5
- "commit": "f8e385e4389fe2fdc6fd2a4366a7f2a7389faacb"
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(kvJson, {}) (the file is kv.json in the app home, or set path); then a plugin that declares inject: ['kv'] calls await ctx.kv.set('theme', 'dark') and ctx.kv.update('visits', (n) => (n ?? 0) + 1)",
12
- "omit": ["**/*.test.*", "**/*.spec.*", "**/*.case.ts"]
12
+ "omit": ["**/*.test.*", "**/*.spec.*", "**/*.case.ts"],
13
+ "imports": {
14
+ "#acryl-kv-json": "acryl-kv-json/src/index.ts"
15
+ }
13
16
  }
@@ -3,9 +3,12 @@
3
3
  * memory and resolves the `kv` port with the function that closes it. The Cordis plugin is a thin wrapper over this, so both run one code
4
4
  * path. This entry imports nothing from Cordis.
5
5
  *
6
- * Every write queues behind the writes before it and persists the whole map: serialize to a temporary file in the same folder, flush it,
7
- * rename it over the file, and flush the folder where the platform allows. The call resolves after the rename, so a crash leaves the old file
8
- * or the new one, never half of one. One process owns the file: two writers would overwrite each other.
6
+ * Every write persists the whole map: serialize to a temporary file in the same folder, flush it, rename it over the file, and flush the
7
+ * folder where the platform allows. The call resolves after the rename, so a crash leaves the old file or the new one, never half of one.
8
+ * Writes are grouped: the calls made in one turn of the event loop, and those that queue while a file write is in flight, are applied in
9
+ * call order and persisted together by one write, so a burst of calls costs one flush and one rename, not one each (on Windows each costs
10
+ * tens of milliseconds). One process owns the
11
+ * file: two writers would overwrite each other.
9
12
  */
10
13
 
11
14
  import { mkdir, open, readdir, readFile, rename, rm } from 'node:fs/promises'
@@ -95,6 +98,20 @@ async function flushFolder(folder: string): Promise<void> {
95
98
  }
96
99
  }
97
100
 
101
+ /** What one call does to the map: the key's new JSON text (`undefined` deletes it), and what the call resolves with. */
102
+ interface Change<T = unknown> {
103
+ readonly key: string
104
+ readonly text: string | undefined
105
+ readonly result: T
106
+ }
107
+
108
+ /** A call waiting for the file write that will contain it. Its change reads the state as the calls before it in the round left it. */
109
+ interface Pending {
110
+ readonly change: (read: (key: string) => string | undefined) => Change
111
+ readonly resolve: (result: unknown) => void
112
+ readonly reject: (error: unknown) => void
113
+ }
114
+
98
115
  /** Open (and create, when missing) a JSON-file store. @throws when the file exists and is not a readable kv file. */
99
116
  export async function openKv(options: OpenKvOptions): Promise<KvOpened> {
100
117
  const file = resolve(options.path)
@@ -103,7 +120,12 @@ export async function openKv(options: OpenKvOptions): Promise<KvOpened> {
103
120
  let state = await load(file)
104
121
  let closed = false
105
122
  let sequence = 0
106
- let tail: Promise<unknown> = Promise.resolve()
123
+ /** Calls waiting for the next file write, in call order. */
124
+ let queue: Pending[] = []
125
+ /** Whether the loop that writes the queue runs; cleared by the loop itself, so a call queued after it ends starts a new one. */
126
+ let draining = false
127
+ /** The latest loop, for `close` to wait on. */
128
+ let drained: Promise<void> = Promise.resolve()
107
129
 
108
130
  const guard = (): void => {
109
131
  if (closed) throw new Error(`acryl-kv-json: ${file} is closed`)
@@ -129,18 +151,62 @@ export async function openKv(options: OpenKvOptions): Promise<KvOpened> {
129
151
  await flushFolder(dirname(file))
130
152
  }
131
153
 
132
- /** Run a change after every earlier one; the map it works on becomes the state only once it is on disk. */
133
- const write = <T>(change: (draft: Map<string, string>) => T): Promise<T> => {
134
- guard()
135
- const task = tail.then(async () => {
154
+ /**
155
+ * Write the queue until it is empty. Each round takes every call queued so far and applies them in order to one copy of the state. A call
156
+ * whose change throws rejects with its error and changes nothing, and the others in the round still apply. The copy becomes the state
157
+ * only once it is on disk. A failed file write rejects every call in its round and leaves the state as it was.
158
+ */
159
+ const drain = async (): Promise<void> => {
160
+ try {
161
+ // Let the calls made in this turn join the first round.
162
+ await Promise.resolve()
163
+ await rounds()
164
+ } finally {
165
+ draining = false
166
+ }
167
+ }
168
+
169
+ const rounds = async (): Promise<void> => {
170
+ while (queue.length > 0) {
171
+ const round = queue
172
+ queue = []
136
173
  const draft = new Map(state)
137
- const result = change(draft)
138
- await persist(draft)
174
+ const applied: { readonly call: Pending; readonly result: unknown }[] = []
175
+ for (const call of round) {
176
+ let change: Change
177
+ try {
178
+ change = call.change(key => draft.get(key))
179
+ } catch (error) {
180
+ call.reject(error)
181
+ continue
182
+ }
183
+ if (change.text === undefined) draft.delete(change.key)
184
+ else draft.set(change.key, change.text)
185
+ applied.push({ call, result: change.result })
186
+ }
187
+ if (applied.length === 0) continue
188
+ try {
189
+ await persist(draft)
190
+ } catch (error) {
191
+ for (const { call } of applied) call.reject(error)
192
+ continue
193
+ }
139
194
  state = draft
140
- return result
195
+ for (const { call, result } of applied) call.resolve(result)
196
+ }
197
+ }
198
+
199
+ /** Queue one change behind every earlier call; it resolves once a file write that contains it is on disk. */
200
+ const write = <T>(change: (read: (key: string) => string | undefined) => Change<T>): Promise<T> => {
201
+ guard()
202
+ const done = new Promise<T>((resolveCall, rejectCall) => {
203
+ queue.push({ change, resolve: resolveCall as (result: unknown) => void, reject: rejectCall })
141
204
  })
142
- tail = task.catch(() => undefined)
143
- return task
205
+ if (!draining) {
206
+ draining = true
207
+ drained = drain()
208
+ }
209
+ return done
144
210
  }
145
211
 
146
212
  const kv: Kv = {
@@ -153,21 +219,19 @@ export async function openKv(options: OpenKvOptions): Promise<KvOpened> {
153
219
  async set(key, value) {
154
220
  checkKey(key)
155
221
  const text = encodeValue(value)
156
- await write(draft => { draft.set(key, text) })
222
+ await write(() => ({ key, text, result: undefined }))
157
223
  },
158
224
  async update<T>(key: string, change: (current: T | undefined) => T) {
159
225
  checkKey(key)
160
- return write(draft => {
161
- const text = draft.get(key)
162
- const next = runChange(change, text === undefined ? undefined : decodeValue<T>(text))
163
- const encoded = encodeValue(next)
164
- draft.set(key, encoded)
165
- return decodeValue<T>(encoded)
226
+ return write(read => {
227
+ const text = read(key)
228
+ const encoded = encodeValue(runChange(change, text === undefined ? undefined : decodeValue<T>(text)))
229
+ return { key, text: encoded, result: decodeValue<T>(encoded) }
166
230
  })
167
231
  },
168
232
  async delete(key) {
169
233
  checkKey(key)
170
- await write(draft => { draft.delete(key) })
234
+ await write(() => ({ key, text: undefined, result: undefined }))
171
235
  },
172
236
  async list(prefix = '') {
173
237
  guard()
@@ -179,7 +243,7 @@ export async function openKv(options: OpenKvOptions): Promise<KvOpened> {
179
243
  kv,
180
244
  close: async () => {
181
245
  closed = true
182
- await tail
246
+ await drained
183
247
  },
184
248
  }
185
249
  }
@@ -2,7 +2,7 @@
2
2
  "source": {
3
3
  "repo": "acryldev/acryl",
4
4
  "path": "plugins/acryl-kv-json",
5
- "commit": "f8e385e4389fe2fdc6fd2a4366a7f2a7389faacb"
5
+ "commit": "20bdc263d6f0f8febbd967007bda4cf4a802efd2"
6
6
  },
7
7
  "vendored": [
8
8
  "src",
@@ -10,7 +10,7 @@
10
10
  "LICENSE"
11
11
  ],
12
12
  "rewrittenSpecifiers": 6,
13
- "digest": "sha256:d8e3e7f9c98399d0f21f29f91323b4ce57646e59e312d4175978259d618a88cf",
13
+ "digest": "sha256:10613b53bdcf15cce86d6dce5e606d0cd93367cc2569b7603cbf157ebc557548",
14
14
  "testsNotRun": {
15
15
  "tests/loader.spec.ts": "loads the plugin through the real Loader (@deepseek-ai/cordis-plugin-loader), which generated apps do not install; the plain opener it wraps is tested here by open.spec.ts and conformance.spec.ts",
16
16
  "tests/crash.spec.ts": "kills a child process that imports the package build (lib/), which a copy vendored into an app does not have; it runs in ACRYL",
@@ -89,6 +89,11 @@ const drainIncoming = (incoming: IncomingMessage | Http2ServerRequest): void =>
89
89
  }
90
90
  }
91
91
 
92
+ // A body reader the app stopped using (Readable.toWeb behind bodyLimit, for one) still listens for
93
+ // data and pauses the request again as soon as its queue is full, so the drain would stall after
94
+ // one chunk and the timer would close a socket with unread data, which the kernel answers with RST.
95
+ // Nobody reads the body any more, so the drain is the only data listener.
96
+ incoming.removeAllListeners('data')
92
97
  incoming.on('data', onData)
93
98
  incoming.on('end', cleanup)
94
99
  incoming.on('error', cleanup)
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * Client components without a bundler. `clientModules({ root: 'src/client' })` is an app that serves the TypeScript and JSX modules of a
3
3
  * folder to the browser as ES modules: a `.tsx`/`.ts`/`.jsx` file compiled with the app's own `typescript` (JSX for `acrylblends/jsx/dom`),
4
- * a `.js`/`.mjs` file as it is. The framework's own browser imports (`acrylblends/jsx/dom` with its runtimes and `client`, and
5
- * `acrylblends/client`) are rewritten to two self-contained files the same app serves, at a URL relative to each module, so a page needs
4
+ * a `.js`/`.mjs` file as it is. The framework's own browser imports (`acrylblends/jsx/dom` with its runtimes and `client`,
5
+ * `acrylblends/client` and `acrylblends/read-sse`) are rewritten to three self-contained files the same app serves, at a URL relative to each module, so a page needs
6
6
  * one `<script type="module" src="/client/counter.tsx">` and no import map, under whatever path the app is routed at.
7
7
  *
8
8
  * Production runs without `typescript` (a deploy that leaves out devDependencies): `buildClientModules` writes the same compiled modules
9
- * and the two browser files into a folder, and with `NODE_ENV=production` and `compiled` set, only that folder is served.
9
+ * and the three browser files into a folder, and with `NODE_ENV=production` and `compiled` set, only that folder is served.
10
10
  */
11
11
 
12
12
  import { copyFileSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs'
@@ -16,7 +16,7 @@ import { Acryl } from '../../acryl-router/src/index.ts'
16
16
  import { basePath } from '../../acryl-router/src/helpers/index.ts'
17
17
  import { MissingTypeScriptError, compileModule } from './transpile.ts'
18
18
 
19
- /** The folder the two browser files are served from, below the app's mount. A source folder of this name is not served. */
19
+ /** The folder the three browser files are served from, below the app's mount. A source folder of this name is not served. */
20
20
  const RUNTIME_DIR = '@acrylblends'
21
21
  /** The framework's browser imports and the browser file that provides each. */
22
22
  const BROWSER_IMPORTS: Readonly<Record<string, string>> = {
@@ -25,8 +25,9 @@ const BROWSER_IMPORTS: Readonly<Record<string, string>> = {
25
25
  'acrylblends/jsx/dom/jsx-dev-runtime': 'jsx-dom.js',
26
26
  'acrylblends/jsx/dom/client': 'jsx-dom.js',
27
27
  'acrylblends/client': 'client.js',
28
+ 'acrylblends/read-sse': 'read-sse.js',
28
29
  }
29
- const RUNTIME_FILES = ['jsx-dom.js', 'client.js'] as const
30
+ const RUNTIME_FILES = ['jsx-dom.js', 'client.js', 'read-sse.js'] as const
30
31
  const COMPILED = new Set(['.ts', '.tsx', '.jsx', '.mts'])
31
32
  const SERVED = new Set([...COMPILED, '.js', '.mjs'])
32
33
  const JS_TYPE = 'text/javascript; charset=utf-8'
@@ -41,7 +42,7 @@ export interface ClientModulesOptions {
41
42
  readonly compiled?: string | undefined
42
43
  /** At most this many bytes of compiled modules are kept in memory (least recently served first out); 16 MiB when not given. */
43
44
  readonly cacheBytes?: number | undefined
44
- /** The folder holding `jsx-dom.js` and `client.js`; the framework's own when not given. */
45
+ /** The folder holding `jsx-dom.js`, `client.js` and `read-sse.js`; the framework's own when not given. */
45
46
  readonly runtimeDir?: string | undefined
46
47
  }
47
48
 
@@ -51,11 +52,11 @@ export interface BuildClientModulesOptions {
51
52
  readonly root: string
52
53
  /** Where the compiled modules go, at the same relative paths and file names; it must not lie inside `root`. */
53
54
  readonly outDir: string
54
- /** The folder holding `jsx-dom.js` and `client.js`; the framework's own when not given. */
55
+ /** The folder holding `jsx-dom.js`, `client.js` and `read-sse.js`; the framework's own when not given. */
55
56
  readonly runtimeDir?: string | undefined
56
57
  }
57
58
 
58
- /** The framework's two browser files, next to the built module that contains this code. */
59
+ /** The framework's three browser files, next to the built module that contains this code. */
59
60
  const defaultRuntimeDir = (): string => fileURLToPath(new URL('./browser/', import.meta.url))
60
61
 
61
62
  /** A module path below the root (`widgets/counter.tsx`) with the URL prefix that reaches the browser files from it. */
@@ -264,7 +265,7 @@ function sourceFiles(root: string, dir = root): string[] {
264
265
 
265
266
  /**
266
267
  * Compile a folder of client modules once, for a production that has no `typescript`: each module is written to `outDir` at the same
267
- * relative path and file name (so the page's URLs stay the same), compiled exactly as {@link clientModules} compiles it, and the two
268
+ * relative path and file name (so the page's URLs stay the same), compiled exactly as {@link clientModules} compiles it, and the three
268
269
  * browser files are copied to `outDir/@acrylblends/`. Files already in `outDir` that no source produces are left there.
269
270
  * @param options - the source folder and the output folder.
270
271
  * @returns the paths written, relative to `outDir`.
@@ -2,7 +2,7 @@
2
2
  "source": {
3
3
  "repo": "acryldev/acryl",
4
4
  "path": "plugins/acryl-node-server",
5
- "commit": "f8e385e4389fe2fdc6fd2a4366a7f2a7389faacb"
5
+ "commit": "20bdc263d6f0f8febbd967007bda4cf4a802efd2"
6
6
  },
7
7
  "vendored": [
8
8
  "src",
@@ -10,7 +10,7 @@
10
10
  "LICENSE"
11
11
  ],
12
12
  "rewrittenSpecifiers": 31,
13
- "digest": "sha256:e4f98422082758912834a6e454505230a18ac543b4851f6eb697b628a1acec4e",
13
+ "digest": "sha256:4142980d289b36c661e572ecbb5f19f06f0c95fc277ce4dca9b543d366c291e8",
14
14
  "testsNotRun": {
15
15
  "tests/client-modules.spec.ts": "links typescript from the plugin's own node_modules (ACRYL's workspace layout); clientModules and build-client are exercised here by the templates' tests and verify:scaffolder",
16
16
  "tests/register.spec.ts": "links typescript from the plugin's own node_modules (ACRYL's workspace layout); the hook is exercised here by the templates' own .tsx tests and verify:scaffolder"
@@ -0,0 +1,3 @@
1
+ /** `readSSE` (`acrylblends/read-sse`) as one browser module with no imports, served by `clientModules`. */
2
+
3
+ export * from '../helpers/read-sse.ts'