acrylblends 0.7.5 → 0.7.7
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.js +277 -5
- package/dist/index.js.map +1 -1
- package/docs/framework/UPSTREAM.md +1 -1
- package/docs/framework/guides/data.md +101 -2
- package/docs/framework/upstream.json +2 -2
- package/lib/database-D_UN6We8.d.ts +44 -0
- package/lib/db/sqlite.d.ts +1 -1
- package/lib/db.d.ts +84 -1
- package/lib/kv/db.d.ts +12 -0
- package/lib/kv/db.js +57 -0
- package/lib/kv/json.d.ts +11 -0
- package/lib/kv/json.js +161 -0
- package/lib/kv-DWDm0Lja.d.ts +39 -0
- package/lib/kv.d.ts +93 -0
- package/lib/kv.js +2 -0
- package/lib/src-DRFVEQQb.js +90 -0
- package/package.json +13 -1
- package/plugins/acryl-db/upstream.json +1 -1
- package/plugins/acryl-db-sqlite/upstream.json +1 -1
- package/plugins/acryl-inspect/upstream.json +1 -1
- package/plugins/acryl-kv/LICENSE +47 -0
- package/plugins/acryl-kv/plugin.json +12 -0
- package/plugins/acryl-kv/src/check.ts +58 -0
- package/plugins/acryl-kv/src/copy.ts +39 -0
- package/plugins/acryl-kv/src/cordis.ts +13 -0
- package/plugins/acryl-kv/src/index.ts +14 -0
- package/plugins/acryl-kv/src/kv.ts +37 -0
- package/plugins/acryl-kv/upstream.json +18 -0
- package/plugins/acryl-kv-db/LICENSE +47 -0
- package/plugins/acryl-kv-db/plugin.json +13 -0
- package/plugins/acryl-kv-db/src/index.ts +37 -0
- package/plugins/acryl-kv-db/src/open.ts +80 -0
- package/plugins/acryl-kv-db/upstream.json +21 -0
- package/plugins/acryl-kv-json/LICENSE +47 -0
- package/plugins/acryl-kv-json/plugin.json +13 -0
- package/plugins/acryl-kv-json/src/index.ts +71 -0
- package/plugins/acryl-kv-json/src/open.ts +185 -0
- package/plugins/acryl-kv-json/upstream.json +22 -0
- package/plugins/acryl-node-server/src/index.ts +12 -1
- package/plugins/acryl-node-server/upstream.json +2 -2
- package/plugins/acryl-router/upstream.json +1 -1
- package/plugins/acryl-router-http/upstream.json +1 -1
- package/plugins/acryl-state/upstream.json +1 -1
- package/plugins/desktop/upstream.json +1 -1
- package/plugins/desktop-web/upstream.json +1 -1
- package/plugins/tsconfig.json +2 -0
- package/templates/todo/upstream.json +1 -1
- package/lib/index-D4FAsBZu.d.ts +0 -85
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Kv } from './kv.ts'
|
|
2
|
+
|
|
3
|
+
export interface CopyKvOptions {
|
|
4
|
+
/** Replace keys the target already has. Without it, a shared key stops the copy before anything is written. */
|
|
5
|
+
readonly overwrite?: boolean
|
|
6
|
+
/** Copy only the keys that start with this. */
|
|
7
|
+
readonly prefix?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface CopyKvResult {
|
|
11
|
+
/** Keys written to the target. */
|
|
12
|
+
readonly copied: number
|
|
13
|
+
/** Of those, keys the target already had. */
|
|
14
|
+
readonly overwritten: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Copy every key of one store into another: the import when an app moves from a JSON file to SQL (the file opened with
|
|
19
|
+
* `acryl-kv-json` is the source, `kvOnDb` the target). The source is not changed.
|
|
20
|
+
* @throws when the target has a key the source has and `overwrite` is not set; nothing is written then.
|
|
21
|
+
*/
|
|
22
|
+
export async function copyKv(source: Kv, target: Kv, options: CopyKvOptions = {}): Promise<CopyKvResult> {
|
|
23
|
+
const keys = await source.list(options.prefix)
|
|
24
|
+
const existing = new Set(await target.list(options.prefix))
|
|
25
|
+
const shared = keys.filter(key => existing.has(key))
|
|
26
|
+
if (shared.length > 0 && options.overwrite !== true) {
|
|
27
|
+
const shown = shared.slice(0, 5).map(key => JSON.stringify(key)).join(', ')
|
|
28
|
+
throw new Error(`copyKv: the target already has ${shared.length} of these keys (${shown}${shared.length > 5 ? ', ...' : ''}); pass overwrite to replace them`)
|
|
29
|
+
}
|
|
30
|
+
let copied = 0
|
|
31
|
+
for (const key of keys) {
|
|
32
|
+
const value = await source.get(key)
|
|
33
|
+
// A key deleted from the source while copying is not copied.
|
|
34
|
+
if (value === undefined) continue
|
|
35
|
+
await target.set(key, value)
|
|
36
|
+
copied += 1
|
|
37
|
+
}
|
|
38
|
+
return { copied, overwritten: shared.length }
|
|
39
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `ctx.kv` type for Cordis plugins: `import type {} from 'acryl-kv/cordis'` beside `inject: ['kv']`. Kept out of the root entry, so
|
|
3
|
+
* plain code that uses the port never names Cordis.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type {} from '@deepseek-ai/cordis'
|
|
7
|
+
import type { Kv } from './kv.ts'
|
|
8
|
+
|
|
9
|
+
declare module '@deepseek-ai/cordis' {
|
|
10
|
+
interface Context {
|
|
11
|
+
kv: Kv
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `kv` port: named JSON documents for an app too small for SQL, whichever provider serves it (`acryl-kv-json` for one file,
|
|
3
|
+
* `acryl-kv-db` over the `db` port). Plain code opens a store with a provider's `openKv`; a Cordis plugin declares `inject: ['kv']`, imports
|
|
4
|
+
* `acryl-kv/cordis` for the `ctx.kv` type, and never imports a provider.
|
|
5
|
+
*
|
|
6
|
+
* This entry imports nothing from Cordis, at runtime or in its types.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The service name every `kv` provider registers. */
|
|
10
|
+
export const KV_SERVICE = 'kv'
|
|
11
|
+
|
|
12
|
+
export type { JsonValue, Kv, KvOpened, NotPromise } from './kv.ts'
|
|
13
|
+
export { checkKey, codePoints, compareKeys, decodeValue, encodeValue, runChange } from './check.ts'
|
|
14
|
+
export { copyKv, type CopyKvOptions, type CopyKvResult } from './copy.ts'
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** `T`, except a promise: the callback of `update` is synchronous, and an async one is a type error. */
|
|
2
|
+
export type NotPromise<T> = T extends PromiseLike<unknown> ? never : T
|
|
3
|
+
|
|
4
|
+
/** Anything JSON can hold. */
|
|
5
|
+
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Named JSON documents. Every call returns a promise, so a file, SQLite and Postgres answer alike.
|
|
9
|
+
*
|
|
10
|
+
* Keys are non-empty strings. Values are JSON: `undefined`, functions, symbols, bigints and non-finite numbers are refused with a
|
|
11
|
+
* `TypeError` before anything is written, and a value read back is a copy.
|
|
12
|
+
*
|
|
13
|
+
* There is no `close`: the code that opened a store closes it (see {@link KvOpened}), so a plugin cannot close the store another uses.
|
|
14
|
+
*/
|
|
15
|
+
export interface Kv {
|
|
16
|
+
/** The value stored under the key, or `undefined`. */
|
|
17
|
+
get<T = JsonValue>(key: string): Promise<T | undefined>
|
|
18
|
+
/** Store a value under the key, replacing any. Resolves once it is durable. */
|
|
19
|
+
set<T = JsonValue>(key: string, value: T): Promise<void>
|
|
20
|
+
/**
|
|
21
|
+
* Replace the value under the key by `change(current)`, atomically: `current` is `undefined` when the key is missing, `change` is
|
|
22
|
+
* synchronous (an `async` function is a type error, and a returned promise is rejected at run time: read what you need first, then update), and no other write to the key happens between reading it and writing the result. Resolves the new value. A `change` that
|
|
23
|
+
* throws, or returns something that is not JSON, leaves the value as it was and rejects with its error.
|
|
24
|
+
*/
|
|
25
|
+
update<T = JsonValue>(key: string, change: (current: T | undefined) => NotPromise<T>): Promise<T>
|
|
26
|
+
/** Remove the key. A missing key is not an error. */
|
|
27
|
+
delete(key: string): Promise<void>
|
|
28
|
+
/** The keys that start with the prefix (all of them without one), sorted by code point (the order of their UTF-8 bytes, not of UTF-16 units). */
|
|
29
|
+
list(prefix?: string): Promise<string[]>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** An open store and the one function that closes it. */
|
|
33
|
+
export interface KvOpened {
|
|
34
|
+
readonly kv: Kv
|
|
35
|
+
/** Waits for the writes in flight, then releases the storage. Calling it again does nothing; calls on `kv` after it reject. */
|
|
36
|
+
close(): Promise<void>
|
|
37
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"source": {
|
|
3
|
+
"repo": "acryldev/acryl",
|
|
4
|
+
"path": "plugins/acryl-kv",
|
|
5
|
+
"commit": "f8e385e4389fe2fdc6fd2a4366a7f2a7389faacb"
|
|
6
|
+
},
|
|
7
|
+
"vendored": [
|
|
8
|
+
"src",
|
|
9
|
+
"tests",
|
|
10
|
+
"LICENSE"
|
|
11
|
+
],
|
|
12
|
+
"rewrittenSpecifiers": 0,
|
|
13
|
+
"digest": "sha256:dbf0d156d0f3d3b394e48cba87b015362f2720c96a3b198ae97ac5bea61c4ae4",
|
|
14
|
+
"testsNotRun": {},
|
|
15
|
+
"notice": [
|
|
16
|
+
"LICENSE"
|
|
17
|
+
]
|
|
18
|
+
}
|
|
@@ -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,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "acryl-kv-db",
|
|
3
|
+
"description": "The kv provider over the db port: a kv table in the app's SQL database, so an app that grows into SQL keeps its data. Vendored from ACRYL.",
|
|
4
|
+
"requires": ["acryl-kv", "acryl-db-sqlite"],
|
|
5
|
+
"inject": ["db"],
|
|
6
|
+
"provides": ["kv"],
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"@deepseek-ai/cordis": "4.0.4",
|
|
9
|
+
"@deepseek-ai/schemastery": "3.18.4"
|
|
10
|
+
},
|
|
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"]
|
|
13
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The kv provider over the `db` port (spec 049 slice 3): a `kv` table in the app's SQL database, provided as `kv` by this Cordis plugin.
|
|
3
|
+
* It injects `db` and nothing else, so it stays PENDING until a `db` provider (`acryl-db-sqlite`, later Postgres) is active, and its
|
|
4
|
+
* consumers (`inject: ['kv']`) wait behind it. Replacing the row with `acryl-kv-json` does not touch them.
|
|
5
|
+
*
|
|
6
|
+
* It owns no connection, so unloading only withdraws `kv`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
10
|
+
import z from '@deepseek-ai/schemastery'
|
|
11
|
+
import { KV_SERVICE } from '../../acryl-kv/src/index.ts'
|
|
12
|
+
import { kvOnDb } from './open.ts'
|
|
13
|
+
|
|
14
|
+
export { kvOnDb, type KvOnDbOptions } from './open.ts'
|
|
15
|
+
|
|
16
|
+
/** Stable Cordis plugin name, equal to the package name and the Loader row id. */
|
|
17
|
+
export const name = 'acryl-kv-db'
|
|
18
|
+
|
|
19
|
+
/** The service this plugin needs: Cordis starts it only while a `db` provider is active. */
|
|
20
|
+
export const inject = ['db']
|
|
21
|
+
|
|
22
|
+
export interface Config {
|
|
23
|
+
table?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const Config: z<Config> = z.object({
|
|
27
|
+
table: z.string().description('The table that holds the documents. Defaults to kv.'),
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
export async function apply(ctx: Context, rawConfig: Config = {}): Promise<void> {
|
|
31
|
+
const config = Config(rawConfig)
|
|
32
|
+
const db = ctx.get('db') as Parameters<typeof kvOnDb>[0]
|
|
33
|
+
const kv = kvOnDb(db, config.table === undefined ? {} : { table: config.table })
|
|
34
|
+
await ctx.effect(async function* () {
|
|
35
|
+
yield ctx.reflect.provide(KV_SERVICE, kv)
|
|
36
|
+
}, 'acryl-kv-db')
|
|
37
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plain way to use SQL as the `kv` store, for code without Cordis (an acrylblends app): `kvOnDb(db)` gives the `kv` port over a `kv`
|
|
3
|
+
* table of the `db` port (SQLite today, Postgres after spec 049 slice 2), so an app that grows from a JSON file into SQL keeps its data. The
|
|
4
|
+
* Cordis plugin is a thin wrapper over it. This entry imports nothing from Cordis.
|
|
5
|
+
*
|
|
6
|
+
* The table is created on first use with `CREATE TABLE IF NOT EXISTS`, so it needs no migration of the app's. `update` reads and writes in
|
|
7
|
+
* one `db.transaction`. `list` selects with `substr(key, 1, n) = ?`, not `LIKE`, so `%`, `_` and letter case need no escaping, and sorts by
|
|
8
|
+
* code point in the end, whatever collation the database sorts with.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Db } from '../../acryl-db/src/index.ts'
|
|
12
|
+
import { checkKey, codePoints, compareKeys, decodeValue, encodeValue, type Kv, runChange } from '../../acryl-kv/src/index.ts'
|
|
13
|
+
|
|
14
|
+
export type { Kv } from '../../acryl-kv/src/index.ts'
|
|
15
|
+
|
|
16
|
+
export interface KvOnDbOptions {
|
|
17
|
+
/** The table that holds the documents. Defaults to `kv`; a plain SQL identifier. */
|
|
18
|
+
readonly table?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const IDENTIFIER = /^[a-z_][a-z0-9_]*$/u
|
|
22
|
+
|
|
23
|
+
/** The `kv` port over a `db`. It does not own the database: whoever opened it closes it, and calls after that reject. */
|
|
24
|
+
export function kvOnDb(db: Db, options: KvOnDbOptions = {}): Kv {
|
|
25
|
+
const table = options.table ?? 'kv'
|
|
26
|
+
if (!IDENTIFIER.test(table)) throw new TypeError(`acryl-kv-db: the table name must be a plain lower case SQL identifier, got ${JSON.stringify(table)}`)
|
|
27
|
+
|
|
28
|
+
let ready: Promise<void> | undefined
|
|
29
|
+
const ensure = (): Promise<void> => {
|
|
30
|
+
ready ??= db.exec(`CREATE TABLE IF NOT EXISTS ${table} (key TEXT PRIMARY KEY, value TEXT NOT NULL)`).catch((error: unknown) => {
|
|
31
|
+
ready = undefined
|
|
32
|
+
throw error
|
|
33
|
+
})
|
|
34
|
+
return ready
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Run a statement after the table exists. The table's statement and this one are both handed to the database at once (its queue keeps
|
|
38
|
+
* their order), so a `close()` called right after a write cannot slip between them and leave the write on a closed database.
|
|
39
|
+
*/
|
|
40
|
+
const after = async <T>(work: () => Promise<T>): Promise<T> => {
|
|
41
|
+
const created = ensure()
|
|
42
|
+
const result = work()
|
|
43
|
+
const [, value] = await Promise.all([created, result])
|
|
44
|
+
return value
|
|
45
|
+
}
|
|
46
|
+
const upsert = `INSERT INTO ${table} (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value`
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
async get<T>(key: string) {
|
|
50
|
+
checkKey(key)
|
|
51
|
+
const row = await after(() => db.get<{ value: string }>(`SELECT value FROM ${table} WHERE key = ?`, key))
|
|
52
|
+
return row === undefined ? undefined : decodeValue<T>(row.value)
|
|
53
|
+
},
|
|
54
|
+
async set(key, value) {
|
|
55
|
+
checkKey(key)
|
|
56
|
+
const text = encodeValue(value)
|
|
57
|
+
await after(() => db.run(upsert, key, text))
|
|
58
|
+
},
|
|
59
|
+
async update<T>(key: string, change: (current: T | undefined) => T) {
|
|
60
|
+
checkKey(key)
|
|
61
|
+
return after(() => db.transaction(async tx => {
|
|
62
|
+
const row = await tx.get<{ value: string }>(`SELECT value FROM ${table} WHERE key = ?`, key)
|
|
63
|
+
const next = runChange(change, row === undefined ? undefined : decodeValue<T>(row.value))
|
|
64
|
+
const text = encodeValue(next)
|
|
65
|
+
await tx.run(upsert, key, text)
|
|
66
|
+
return decodeValue<T>(text)
|
|
67
|
+
}))
|
|
68
|
+
},
|
|
69
|
+
async delete(key) {
|
|
70
|
+
checkKey(key)
|
|
71
|
+
await after(() => db.run(`DELETE FROM ${table} WHERE key = ?`, key))
|
|
72
|
+
},
|
|
73
|
+
async list(prefix = '') {
|
|
74
|
+
const rows = await after(() => prefix === ''
|
|
75
|
+
? db.all<{ key: string }>(`SELECT key FROM ${table}`)
|
|
76
|
+
: db.all<{ key: string }>(`SELECT key FROM ${table} WHERE substr(key, 1, ?) = ?`, codePoints(prefix), prefix))
|
|
77
|
+
return rows.map(row => row.key).sort(compareKeys)
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"source": {
|
|
3
|
+
"repo": "acryldev/acryl",
|
|
4
|
+
"path": "plugins/acryl-kv-db",
|
|
5
|
+
"commit": "f8e385e4389fe2fdc6fd2a4366a7f2a7389faacb"
|
|
6
|
+
},
|
|
7
|
+
"vendored": [
|
|
8
|
+
"src",
|
|
9
|
+
"tests",
|
|
10
|
+
"LICENSE"
|
|
11
|
+
],
|
|
12
|
+
"rewrittenSpecifiers": 10,
|
|
13
|
+
"digest": "sha256:266a44080a78b3d10fc05e5b83a39599e44a5be998eb97368aa07507ac4e3a48",
|
|
14
|
+
"testsNotRun": {
|
|
15
|
+
"tests/loader.spec.ts": "loads the plugin through the real Loader (@deepseek-ai/cordis-plugin-loader), which generated apps do not install; kvOnDb is tested here by open.spec.ts and conformance.spec.ts",
|
|
16
|
+
"tests/plain-entry-imports.spec.ts": "a copy vendored into an app (with relative imports, no build of its own) has neither `lib/`; the app's own typecheck is what proves the copy names no Cordis type it does not provide."
|
|
17
|
+
},
|
|
18
|
+
"notice": [
|
|
19
|
+
"LICENSE"
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -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,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "acryl-kv-json",
|
|
3
|
+
"description": "The JSON-file provider of the kv port: one file in the app home, rewritten atomically (temp file, then rename) on every write. Vendored from ACRYL.",
|
|
4
|
+
"requires": ["acryl-kv", "instance"],
|
|
5
|
+
"inject": [],
|
|
6
|
+
"provides": ["kv"],
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"@deepseek-ai/cordis": "4.0.4",
|
|
9
|
+
"@deepseek-ai/schemastery": "3.18.4"
|
|
10
|
+
},
|
|
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"]
|
|
13
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The JSON-file provider of the `kv` port (spec 049 slice 3): one file, rewritten atomically on every write, opened with the plain
|
|
3
|
+
* `openKv` and provided as `kv` by this Cordis plugin. App plugins declare `inject: ['kv']` and import types from `acryl-kv` only, so
|
|
4
|
+
* replacing this row with `acryl-kv-db` does not touch them.
|
|
5
|
+
*
|
|
6
|
+
* Unloading withdraws `kv` first, so consumers stop calling, and then closes the store: the close waits for the writes in flight.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
10
|
+
import { fileURLToPath } from 'node:url'
|
|
11
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
12
|
+
import z from '@deepseek-ai/schemastery'
|
|
13
|
+
import { KV_SERVICE } from '../../acryl-kv/src/index.ts'
|
|
14
|
+
import { openKv } from './open.ts'
|
|
15
|
+
|
|
16
|
+
export { openKv, type OpenKvOptions } from './open.ts'
|
|
17
|
+
|
|
18
|
+
/** Stable Cordis plugin name, equal to the package name and the Loader row id. */
|
|
19
|
+
export const name = 'acryl-kv-json'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* What this plugin reads of the runtime's optional `appInstance` service: its home folder. The service belongs to the runtime (and to an
|
|
23
|
+
* app's own instance plugin), so this plugin declares no type for it on Cordis' Context; it checks the one field it uses at runtime.
|
|
24
|
+
*/
|
|
25
|
+
interface AppHome {
|
|
26
|
+
readonly home: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isAppHome(value: unknown): value is AppHome {
|
|
30
|
+
return typeof value === 'object' && value !== null && 'home' in value && typeof value.home === 'string'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface Config {
|
|
34
|
+
path?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const Config: z<Config> = z.object({
|
|
38
|
+
path: z.string().description('The JSON file, absolute or relative to the app. Defaults to kv.json in the ACRYL app home; required where there is none.'),
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
/** The file name used in the app home when `path` is not configured. */
|
|
42
|
+
export const DEFAULT_FILE = 'kv.json'
|
|
43
|
+
|
|
44
|
+
/** A configured path as a file path: absolute as given, relative to the Loader's base URL (the app) otherwise. */
|
|
45
|
+
function resolveFromApp(ctx: Context, path: string): string {
|
|
46
|
+
if (path.startsWith('file:')) return fileURLToPath(path)
|
|
47
|
+
if (isAbsolute(path)) return path
|
|
48
|
+
const base = ctx.baseUrl
|
|
49
|
+
if (base === undefined || !base.startsWith('file:')) {
|
|
50
|
+
throw new Error(`acryl-kv-json: path ${JSON.stringify(path)} is relative, but this app has no file base URL to resolve it from; give an absolute path`)
|
|
51
|
+
}
|
|
52
|
+
const appDir = base.endsWith('/') ? fileURLToPath(base) : dirname(fileURLToPath(base))
|
|
53
|
+
return resolve(appDir, path)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function apply(ctx: Context, rawConfig: Config = {}): Promise<void> {
|
|
57
|
+
const config = Config(rawConfig)
|
|
58
|
+
// The app home comes from the runtime's `appInstance` service when this runs inside an ACRYL app; a plain framework app names its file.
|
|
59
|
+
const appInstance: unknown = ctx.get('appInstance')
|
|
60
|
+
let file: string
|
|
61
|
+
if (config.path !== undefined) file = resolveFromApp(ctx, config.path)
|
|
62
|
+
else if (isAppHome(appInstance)) file = join(appInstance.home, DEFAULT_FILE)
|
|
63
|
+
else throw new Error('acryl-kv-json: no file path; set `path` in this row\'s config (there is no ACRYL app home to default to)')
|
|
64
|
+
|
|
65
|
+
// Disposers run in reverse: the service is withdrawn before the store closes.
|
|
66
|
+
await ctx.effect(async function* () {
|
|
67
|
+
const opened = await openKv({ path: file })
|
|
68
|
+
yield () => opened.close()
|
|
69
|
+
yield ctx.reflect.provide(KV_SERVICE, opened.kv)
|
|
70
|
+
}, `acryl-kv-json: ${file}`)
|
|
71
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plain way to open the JSON-file provider, for code without Cordis (an acrylblends app): `openKv({ path })` reads one JSON file into
|
|
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
|
+
* path. This entry imports nothing from Cordis.
|
|
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.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { mkdir, open, readdir, readFile, rename, rm } from 'node:fs/promises'
|
|
12
|
+
import { basename, dirname, join, resolve } from 'node:path'
|
|
13
|
+
import { checkKey, compareKeys, decodeValue, encodeValue, type Kv, type KvOpened, runChange } from '../../acryl-kv/src/index.ts'
|
|
14
|
+
|
|
15
|
+
export type { Kv, KvOpened } from '../../acryl-kv/src/index.ts'
|
|
16
|
+
|
|
17
|
+
export interface OpenKvOptions {
|
|
18
|
+
/** The JSON file; its folder is created if missing. */
|
|
19
|
+
readonly path: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The file's own format version, for a later change of layout. */
|
|
23
|
+
const FORMAT = 1
|
|
24
|
+
|
|
25
|
+
/** Stray temporary files from a crash end in `.tmp` and start with the file's name. */
|
|
26
|
+
const tempPrefix = (file: string): string => `${basename(file)}.`
|
|
27
|
+
|
|
28
|
+
/** The file content for a map of key to JSON text, keys in code point order, so the same data is the same bytes. */
|
|
29
|
+
function serialize(entries: ReadonlyMap<string, string>): string {
|
|
30
|
+
const body = [...entries.keys()].sort(compareKeys).map(key => `${JSON.stringify(key)}:${entries.get(key)}`).join(',')
|
|
31
|
+
return `{"version":${FORMAT},"entries":{${body}}}\n`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function unreadable(file: string, reason: string): Error {
|
|
35
|
+
return new Error(`acryl-kv-json: ${file} is not a readable kv file (${reason}); it was not changed`)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The entries of a file, or none when it does not exist. A file that exists and does not parse is an error: it is never started over. */
|
|
39
|
+
async function load(file: string): Promise<Map<string, string>> {
|
|
40
|
+
let text: string
|
|
41
|
+
try {
|
|
42
|
+
text = await readFile(file, 'utf8')
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return new Map()
|
|
45
|
+
throw error
|
|
46
|
+
}
|
|
47
|
+
let parsed: unknown
|
|
48
|
+
try {
|
|
49
|
+
parsed = JSON.parse(text)
|
|
50
|
+
} catch (error) {
|
|
51
|
+
throw unreadable(file, (error as Error).message)
|
|
52
|
+
}
|
|
53
|
+
if (typeof parsed !== 'object' || parsed === null || (parsed as { version?: unknown }).version !== FORMAT) throw unreadable(file, 'not a version 1 kv file')
|
|
54
|
+
const entries = (parsed as { entries?: unknown }).entries
|
|
55
|
+
if (typeof entries !== 'object' || entries === null || Array.isArray(entries)) throw unreadable(file, 'no entries object')
|
|
56
|
+
return new Map(Object.entries(entries).map(([key, value]) => [key, JSON.stringify(value)]))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Remove the temporary files an earlier crash left beside the file. */
|
|
60
|
+
async function removeStrays(file: string): Promise<void> {
|
|
61
|
+
const prefix = tempPrefix(file)
|
|
62
|
+
for (const name of await readdir(dirname(file))) {
|
|
63
|
+
if (name.startsWith(prefix) && name.endsWith('.tmp')) await rm(join(dirname(file), name), { force: true })
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const RENAME_RETRIES = 8
|
|
68
|
+
|
|
69
|
+
/** Rename over the target. Windows refuses while a scanner or an indexer holds the target for a moment, so it is retried briefly. */
|
|
70
|
+
async function replace(from: string, to: string): Promise<void> {
|
|
71
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
72
|
+
try {
|
|
73
|
+
await rename(from, to)
|
|
74
|
+
return
|
|
75
|
+
} catch (error) {
|
|
76
|
+
const code = (error as NodeJS.ErrnoException).code
|
|
77
|
+
if (attempt >= RENAME_RETRIES || (code !== 'EPERM' && code !== 'EBUSY' && code !== 'EACCES')) throw error
|
|
78
|
+
await new Promise(resolveWait => setTimeout(resolveWait, 10 * (attempt + 1)))
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Flush the folder entry of a renamed file (POSIX); Windows cannot open a folder, and a filesystem that refuses is no worse than before. */
|
|
84
|
+
async function flushFolder(folder: string): Promise<void> {
|
|
85
|
+
if (process.platform === 'win32') return
|
|
86
|
+
try {
|
|
87
|
+
const handle = await open(folder, 'r')
|
|
88
|
+
try {
|
|
89
|
+
await handle.sync()
|
|
90
|
+
} finally {
|
|
91
|
+
await handle.close()
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
// Best effort: the rename itself is already atomic.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Open (and create, when missing) a JSON-file store. @throws when the file exists and is not a readable kv file. */
|
|
99
|
+
export async function openKv(options: OpenKvOptions): Promise<KvOpened> {
|
|
100
|
+
const file = resolve(options.path)
|
|
101
|
+
await mkdir(dirname(file), { recursive: true })
|
|
102
|
+
await removeStrays(file)
|
|
103
|
+
let state = await load(file)
|
|
104
|
+
let closed = false
|
|
105
|
+
let sequence = 0
|
|
106
|
+
let tail: Promise<unknown> = Promise.resolve()
|
|
107
|
+
|
|
108
|
+
const guard = (): void => {
|
|
109
|
+
if (closed) throw new Error(`acryl-kv-json: ${file} is closed`)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Persist a map: the file is the old one or this one, never between. */
|
|
113
|
+
const persist = async (next: ReadonlyMap<string, string>): Promise<void> => {
|
|
114
|
+
sequence += 1
|
|
115
|
+
const temp = join(dirname(file), `${tempPrefix(file)}${process.pid}.${sequence}.tmp`)
|
|
116
|
+
try {
|
|
117
|
+
const handle = await open(temp, 'w')
|
|
118
|
+
try {
|
|
119
|
+
await handle.writeFile(serialize(next), 'utf8')
|
|
120
|
+
await handle.sync()
|
|
121
|
+
} finally {
|
|
122
|
+
await handle.close()
|
|
123
|
+
}
|
|
124
|
+
await replace(temp, file)
|
|
125
|
+
} catch (error) {
|
|
126
|
+
await rm(temp, { force: true })
|
|
127
|
+
throw error
|
|
128
|
+
}
|
|
129
|
+
await flushFolder(dirname(file))
|
|
130
|
+
}
|
|
131
|
+
|
|
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 () => {
|
|
136
|
+
const draft = new Map(state)
|
|
137
|
+
const result = change(draft)
|
|
138
|
+
await persist(draft)
|
|
139
|
+
state = draft
|
|
140
|
+
return result
|
|
141
|
+
})
|
|
142
|
+
tail = task.catch(() => undefined)
|
|
143
|
+
return task
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const kv: Kv = {
|
|
147
|
+
async get<T>(key: string) {
|
|
148
|
+
guard()
|
|
149
|
+
checkKey(key)
|
|
150
|
+
const text = state.get(key)
|
|
151
|
+
return text === undefined ? undefined : decodeValue<T>(text)
|
|
152
|
+
},
|
|
153
|
+
async set(key, value) {
|
|
154
|
+
checkKey(key)
|
|
155
|
+
const text = encodeValue(value)
|
|
156
|
+
await write(draft => { draft.set(key, text) })
|
|
157
|
+
},
|
|
158
|
+
async update<T>(key: string, change: (current: T | undefined) => T) {
|
|
159
|
+
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)
|
|
166
|
+
})
|
|
167
|
+
},
|
|
168
|
+
async delete(key) {
|
|
169
|
+
checkKey(key)
|
|
170
|
+
await write(draft => { draft.delete(key) })
|
|
171
|
+
},
|
|
172
|
+
async list(prefix = '') {
|
|
173
|
+
guard()
|
|
174
|
+
return [...state.keys()].filter(key => key.startsWith(prefix)).sort(compareKeys)
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
kv,
|
|
180
|
+
close: async () => {
|
|
181
|
+
closed = true
|
|
182
|
+
await tail
|
|
183
|
+
},
|
|
184
|
+
}
|
|
185
|
+
}
|