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.
- package/dist/index.d.ts +14 -6
- package/dist/index.js +130 -31
- package/dist/index.js.map +1 -1
- package/docs/framework/README.md +3 -3
- package/docs/framework/UPSTREAM.md +1 -1
- package/docs/framework/agents/index.md +18 -3
- package/docs/framework/getting-started/basic.md +1 -2
- package/docs/framework/getting-started/desktop.md +3 -3
- package/docs/framework/guides/bring-your-own-tool.md +216 -0
- package/docs/framework/guides/client-modules.md +4 -3
- package/docs/framework/guides/create-acrylblends.md +5 -4
- package/docs/framework/guides/data.md +7 -6
- package/docs/framework/helpers/streaming.md +12 -27
- package/docs/framework/upstream.json +3 -3
- package/lib/browser/read-sse.js +84 -0
- package/lib/db/sqlite.d.ts +1 -1
- package/lib/db.d.ts +1 -84
- package/lib/index-BLTYsyc1.d.ts +93 -0
- package/lib/index-D4FAsBZu.d.ts +85 -0
- package/lib/kv/db.d.ts +2 -2
- package/lib/kv/json.d.ts +1 -1
- package/lib/kv/json.js +87 -24
- package/lib/kv.d.ts +1 -92
- package/lib/node-server.d.ts +3 -3
- package/lib/node-server.js +14 -8
- package/lib/read-sse.d.ts +33 -0
- package/lib/read-sse.js +84 -0
- package/package.json +5 -1
- package/plugins/acryl-db/plugin.json +7 -2
- package/plugins/acryl-db/src/conformance.ts +224 -0
- package/plugins/acryl-db/upstream.json +2 -2
- package/plugins/acryl-db-sqlite/plugin.json +4 -1
- package/plugins/acryl-db-sqlite/upstream.json +1 -1
- package/plugins/acryl-door/LICENSE +47 -0
- package/plugins/acryl-door/plugin.json +14 -0
- package/plugins/acryl-door/src/cli/acryl.ts +207 -0
- package/plugins/acryl-door/src/host/api.ts +9 -0
- package/plugins/acryl-door/src/host/audit.ts +64 -0
- package/plugins/acryl-door/src/host/channel.ts +142 -0
- package/plugins/acryl-door/src/host/chat-tools.ts +39 -0
- package/plugins/acryl-door/src/host/door-file.ts +94 -0
- package/plugins/acryl-door/src/host/door.ts +195 -0
- package/plugins/acryl-door/src/host/http.ts +80 -0
- package/plugins/acryl-door/src/host/mcp-routes.ts +83 -0
- package/plugins/acryl-door/src/host/operator-routes.ts +38 -0
- package/plugins/acryl-door/src/host/page-routes.ts +65 -0
- package/plugins/acryl-door/src/host/shim.ts +65 -0
- package/plugins/acryl-door/src/host/verbs.ts +109 -0
- package/plugins/acryl-door/src/index.ts +133 -0
- package/plugins/acryl-door/src/page/api.ts +9 -0
- package/plugins/acryl-door/src/page/channel.ts +135 -0
- package/plugins/acryl-door/src/page/driver/accessibility.ts +163 -0
- package/plugins/acryl-door/src/page/driver/actions.ts +186 -0
- package/plugins/acryl-door/src/page/driver/driver.ts +153 -0
- package/plugins/acryl-door/src/page/driver/ref-table.ts +78 -0
- package/plugins/acryl-door/src/page/driver/sensitivity.ts +49 -0
- package/plugins/acryl-door/src/page/driver/snapshot.ts +132 -0
- package/plugins/acryl-door/src/page/index.ts +40 -0
- package/plugins/acryl-door/src/page/indicator.ts +72 -0
- package/plugins/acryl-door/src/page/main.ts +7 -0
- package/plugins/acryl-door/src/page/protocol.ts +213 -0
- package/plugins/acryl-door/src/page/snapshot-text.ts +18 -0
- package/plugins/acryl-door/src/page/user-input.ts +24 -0
- package/plugins/acryl-door/upstream.json +28 -0
- package/plugins/acryl-inspect/upstream.json +1 -1
- package/plugins/acryl-kv/plugin.json +7 -2
- package/plugins/acryl-kv/src/conformance.ts +226 -0
- package/plugins/acryl-kv/upstream.json +1 -1
- package/plugins/acryl-kv-db/plugin.json +4 -1
- package/plugins/acryl-kv-db/upstream.json +1 -1
- package/plugins/acryl-kv-json/plugin.json +4 -1
- package/plugins/acryl-kv-json/src/open.ts +86 -22
- package/plugins/acryl-kv-json/upstream.json +2 -2
- package/plugins/acryl-node-server/src/adapter/listener.ts +5 -0
- package/plugins/acryl-node-server/src/client-modules.ts +10 -9
- package/plugins/acryl-node-server/upstream.json +2 -2
- package/plugins/acryl-router/src/browser/read-sse.ts +3 -0
- package/plugins/acryl-router/src/helpers/read-sse.ts +111 -0
- package/plugins/acryl-router/src/service.ts +3 -0
- package/plugins/acryl-router/upstream.json +2 -2
- package/plugins/acryl-router-http/upstream.json +1 -1
- package/plugins/acryl-state/upstream.json +1 -1
- package/plugins/agent-panel/client/main.ts +159 -0
- package/plugins/agent-panel/index.ts +232 -0
- package/plugins/agent-panel/plugin.json +21 -0
- package/plugins/builder/index.ts +2 -2
- package/plugins/builder/plugin.json +1 -1
- package/plugins/desktop/main.ts +3 -3
- package/plugins/desktop/plugin.json +2 -2
- package/plugins/desktop/upstream.json +1 -1
- package/plugins/desktop-web/main.ts +2 -2
- package/plugins/desktop-web/plugin.json +2 -2
- package/plugins/desktop-web/upstream.json +1 -1
- package/plugins/extensions/index.ts +19 -1
- package/plugins/extensions/plugin.json +1 -1
- package/plugins/extensions/resolve.ts +17 -3
- package/plugins/manifest/lock.ts +7 -3
- package/plugins/manifest/upgrade-cli.ts +1 -1
- package/plugins/save/cli.ts +1 -1
- package/plugins/save/plugin.json +1 -1
- package/plugins/tsconfig.json +6 -0
- package/shared/AGENTS.core.md +10 -10
- package/shared/DEFAULTS.md +45 -0
- package/shared/SKILL.md +14 -0
- package/templates/agent/AGENTS.md +1 -1
- package/templates/agent/README.md +6 -6
- package/templates/agent/blend.yaml +3 -0
- package/templates/agent/gitignore +1 -0
- package/templates/agent/package.json +8 -8
- package/templates/agent/src/app.ts +46 -17
- package/templates/agent/src/main.ts +2 -2
- package/templates/agent/src/routes.ts +19 -6
- package/templates/agent/template.json +2 -1
- package/templates/agent/test/agent.test.ts +11 -5
- package/templates/agent/test/app.test.ts +30 -9
- package/templates/agent/test/builder.test.ts +35 -24
- package/templates/agent/test/model.test.ts +25 -20
- package/templates/agent/tsconfig.json +48 -50
- package/templates/blank/AGENTS.md +1 -1
- package/templates/blank/README.md +2 -2
- package/templates/blank/gitignore +1 -0
- package/templates/blank/package.json +8 -8
- package/templates/blank/src/about.tsx +2 -2
- package/templates/blank/src/app.ts +29 -9
- package/templates/blank/src/main.ts +1 -1
- package/templates/blank/src/routes.ts +1 -1
- package/templates/blank/test/app.test.ts +29 -10
- package/templates/blank/tsconfig.json +1 -1
- package/templates/chat/AGENTS.md +16 -0
- package/templates/chat/blend.yaml +29 -0
- package/templates/chat/gitignore +6 -0
- package/templates/chat/migrations/001-chat.sql +12 -0
- package/templates/chat/package.json +33 -0
- package/templates/chat/src/app.ts +106 -0
- package/templates/chat/src/chat-app.ts +101 -0
- package/templates/chat/src/client/chat.tsx +115 -0
- package/templates/chat/src/client/tsconfig.json +26 -0
- package/templates/chat/src/db.ts +43 -0
- package/templates/chat/src/door.ts +7 -0
- package/templates/chat/src/llm.ts +42 -0
- package/templates/chat/src/main.ts +28 -0
- package/templates/chat/src/page.tsx +40 -0
- package/templates/chat/src/routes.ts +15 -0
- package/templates/chat/src/runtime.ts +17 -0
- package/templates/chat/template.json +11 -0
- package/templates/chat/test/chat.test.ts +198 -0
- package/templates/chat/test/server.test.ts +78 -0
- package/templates/chat/tsconfig.json +20 -0
- package/templates/nodejs/AGENTS.md +9 -0
- package/templates/nodejs/blend.yaml +26 -0
- package/templates/nodejs/gitignore +3 -0
- package/templates/nodejs/package.json +30 -0
- package/templates/nodejs/src/app.ts +92 -0
- package/templates/nodejs/src/client/counter.tsx +12 -0
- package/templates/nodejs/src/client/tsconfig.json +26 -0
- package/templates/nodejs/src/door.ts +7 -0
- package/templates/nodejs/src/main.ts +25 -0
- package/templates/nodejs/src/page.tsx +29 -0
- package/templates/nodejs/src/routes.ts +40 -0
- package/templates/nodejs/template.json +3 -0
- package/templates/nodejs/test/app.test.ts +78 -0
- package/templates/nodejs/test/server.test.ts +83 -0
- package/templates/nodejs/tsconfig.json +20 -0
- package/templates/notes/AGENTS.md +26 -0
- package/templates/notes/blend.yaml +29 -0
- package/templates/notes/gitignore +6 -0
- package/templates/notes/migrations/001-notes.sql +18 -0
- package/templates/notes/package.json +33 -0
- package/templates/notes/src/app.ts +106 -0
- package/templates/notes/src/auth.ts +57 -0
- package/templates/notes/src/client/counter.tsx +21 -0
- package/templates/notes/src/client/tsconfig.json +26 -0
- package/templates/notes/src/db.ts +50 -0
- package/templates/notes/src/door.ts +7 -0
- package/templates/notes/src/main.ts +28 -0
- package/templates/notes/src/notes-app.ts +205 -0
- package/templates/notes/src/routes.ts +14 -0
- package/templates/notes/src/runtime.ts +17 -0
- package/templates/notes/src/schema.ts +38 -0
- package/templates/notes/src/views.tsx +136 -0
- package/templates/notes/template.json +11 -0
- package/templates/notes/test/notes.test.ts +280 -0
- package/templates/notes/test/server.test.ts +78 -0
- package/templates/notes/tsconfig.json +20 -0
- package/templates/todo/README.md +1 -1
- package/templates/todo/gitignore +1 -0
- package/templates/todo/package.json +8 -8
- package/templates/todo/src/app.ts +32 -12
- package/templates/todo/src/main.ts +1 -1
- package/templates/todo/src/todo.ts +4 -4
- package/templates/todo/test/todo.test.ts +48 -23
- package/templates/todo/tsconfig.json +1 -1
- package/templates/todo/upstream.json +2 -2
- package/lib/database-D_UN6We8.d.ts +0 -44
- 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
|
+
}
|
|
@@ -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
|
}
|
|
@@ -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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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 =
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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(
|
|
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(
|
|
161
|
-
const text =
|
|
162
|
-
const
|
|
163
|
-
|
|
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(
|
|
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
|
|
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": "
|
|
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:
|
|
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`,
|
|
5
|
-
* `acrylblends/client`) are rewritten to
|
|
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
|
|
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
|
|
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 `
|
|
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 `
|
|
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
|
|
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
|
|
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": "
|
|
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:
|
|
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"
|