@storic/cloudflare 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +138 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/persistence.d.ts +15 -0
- package/dist/persistence.d.ts.map +1 -0
- package/dist/persistence.js +159 -0
- package/dist/persistence.js.map +1 -0
- package/dist/sql-storage-client.d.ts +25 -0
- package/dist/sql-storage-client.d.ts.map +1 -0
- package/dist/sql-storage-client.js +107 -0
- package/dist/sql-storage-client.js.map +1 -0
- package/dist/storic-object.d.ts +51 -0
- package/dist/storic-object.d.ts.map +1 -0
- package/dist/storic-object.js +61 -0
- package/dist/storic-object.js.map +1 -0
- package/package.json +38 -0
- package/src/index.ts +36 -0
- package/src/persistence.ts +261 -0
- package/src/sql-storage-client.ts +138 -0
- package/src/storic-object.ts +81 -0
package/README.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# @storic/cloudflare
|
|
2
|
+
|
|
3
|
+
`JsEvaluator` implementation for Storic using Cloudflare [Dynamic Worker Loaders](https://developers.cloudflare.com/workers/runtime-apis/bindings/worker-loader/).
|
|
4
|
+
|
|
5
|
+
Each evaluation generates a worker module with the expression and bindings injected directly into the source. The module is loaded into an isolated worker with no network access (`globalOutbound: null`), providing true sandboxing.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
bun add @storic/cloudflare
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Setup
|
|
14
|
+
|
|
15
|
+
### 1. Add the worker loader binding to your wrangler config
|
|
16
|
+
|
|
17
|
+
```toml
|
|
18
|
+
# wrangler.toml
|
|
19
|
+
[[worker_loaders]]
|
|
20
|
+
binding = "EVALUATOR"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### 2. Wire the layer in your worker
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
import { Effect, Layer } from "effect";
|
|
27
|
+
import { Store } from "@storic/core";
|
|
28
|
+
import { CloudflareJsEvaluator, WorkerLoaderBinding } from "@storic/cloudflare";
|
|
29
|
+
|
|
30
|
+
export default {
|
|
31
|
+
async fetch(request: Request, env: Env) {
|
|
32
|
+
const StoreLive = Store.layer.pipe(
|
|
33
|
+
Layer.provide(Layer.mergeAll(
|
|
34
|
+
SqlLive,
|
|
35
|
+
CloudflareJsEvaluator.layer.pipe(
|
|
36
|
+
Layer.provide(WorkerLoaderBinding.layer(env.EVALUATOR)),
|
|
37
|
+
),
|
|
38
|
+
)),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
// use StoreLive ...
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## How it works
|
|
47
|
+
|
|
48
|
+
When `evaluate(jsExpr, bindings)` is called:
|
|
49
|
+
|
|
50
|
+
1. Binding names are validated (must be valid JS identifiers)
|
|
51
|
+
2. Binding values are JSON-serialized (must be serializable)
|
|
52
|
+
3. The expression is wrapped in an IIFE with bindings as parameters/arguments
|
|
53
|
+
4. A fresh dynamic worker is spawned via the Worker Loader API
|
|
54
|
+
5. The expression evaluates at module initialization time
|
|
55
|
+
6. The result is returned via a minimal `fetch` handler
|
|
56
|
+
|
|
57
|
+
For example, evaluating `a + b` with `{ a: 10, b: 20 }` generates:
|
|
58
|
+
|
|
59
|
+
```javascript
|
|
60
|
+
let __result;
|
|
61
|
+
let __error;
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
__result = ((a, b) => (a + b))(10, 20);
|
|
65
|
+
} catch (e) {
|
|
66
|
+
__error = (e instanceof Error && e.message) ? e.message : String(e);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export default {
|
|
70
|
+
fetch() {
|
|
71
|
+
if (__error !== undefined) {
|
|
72
|
+
return Response.json({ error: __error }, { status: 400 });
|
|
73
|
+
}
|
|
74
|
+
const t = typeof __result;
|
|
75
|
+
if (t === "function" || t === "symbol" || t === "undefined") {
|
|
76
|
+
return Response.json(
|
|
77
|
+
{ error: "Result is not JSON-serializable: got " + t },
|
|
78
|
+
{ status: 400 },
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
return Response.json({ result: __result });
|
|
83
|
+
} catch (e) {
|
|
84
|
+
const msg = (e instanceof Error && e.message) ? e.message : String(e);
|
|
85
|
+
return Response.json(
|
|
86
|
+
{ error: "Result is not JSON-serializable: " + msg },
|
|
87
|
+
{ status: 400 },
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
This mirrors core's `new Function(...names, 'return (expr)')(...values)` pattern, but runs in an isolated worker with `globalOutbound: null` — no network access, no bindings, no access to the parent worker's environment.
|
|
95
|
+
|
|
96
|
+
## API
|
|
97
|
+
|
|
98
|
+
### `CloudflareJsEvaluator.layer`
|
|
99
|
+
|
|
100
|
+
`Layer.Layer<JsEvaluator, never, WorkerLoaderBinding>`
|
|
101
|
+
|
|
102
|
+
Provides the `JsEvaluator` service. Requires a `WorkerLoaderBinding`.
|
|
103
|
+
|
|
104
|
+
### `WorkerLoaderBinding.layer(loader)`
|
|
105
|
+
|
|
106
|
+
`(loader: WorkerLoader) => Layer.Layer<WorkerLoaderBinding>`
|
|
107
|
+
|
|
108
|
+
Creates a `WorkerLoaderBinding` layer from the `env.EVALUATOR` binding in your worker.
|
|
109
|
+
|
|
110
|
+
### `WorkerLoader`
|
|
111
|
+
|
|
112
|
+
Minimal type for the Cloudflare Worker Loader binding. Matches the `get()` method from the [Worker Loader API](https://developers.cloudflare.com/workers/runtime-apis/bindings/worker-loader/).
|
|
113
|
+
|
|
114
|
+
### `EvaluatorModuleError`
|
|
115
|
+
|
|
116
|
+
Thrown synchronously by `generateEvaluatorModule` when bindings contain invalid identifier names or non-JSON-serializable values. Wrapped into a `TransformError` by the layer.
|
|
117
|
+
|
|
118
|
+
## Notes
|
|
119
|
+
|
|
120
|
+
- **Closed beta**: Dynamic Worker Loaders require [signing up for the closed beta](https://forms.gle/MoeDxE9wNiqdf8ri9). They work locally with `wrangler dev`.
|
|
121
|
+
- **Bindings are JSON-serialized**: Functions, classes, symbols, `undefined`, `BigInt`, and circular references cannot be passed as bindings. Binding names must be valid JavaScript identifiers.
|
|
122
|
+
- **Results must be JSON-serializable**: If the expression evaluates to a function, symbol, `undefined`, or other non-serializable value, the evaluator returns a `TransformError`.
|
|
123
|
+
- **Fresh isolate per evaluation**: Each call uses a random worker ID to avoid stale cached results.
|
|
124
|
+
- **Syntax errors**: If the expression has a syntax error, the dynamic worker module fails to parse and the error surfaces through the Worker Loader API (caught as a `TransformError`).
|
|
125
|
+
|
|
126
|
+
## Testing
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
# Unit tests
|
|
130
|
+
bun test test/
|
|
131
|
+
|
|
132
|
+
# E2E tests (requires wrangler)
|
|
133
|
+
bun run test:e2e
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## License
|
|
137
|
+
|
|
138
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @storic/cloudflare
|
|
3
|
+
*
|
|
4
|
+
* Helper utilities for using Storic with Cloudflare Durable Objects.
|
|
5
|
+
*
|
|
6
|
+
* Provides:
|
|
7
|
+
* - `doStoragePersistence` — Persistence implementation backed by DO's SqlStorage
|
|
8
|
+
* - `StoricDO` — Base class for DOs with automatic Store setup
|
|
9
|
+
* - `sqlStorageLayer` — Low-level SqlClient adapter (for advanced use)
|
|
10
|
+
*/
|
|
11
|
+
export { doStoragePersistence } from "./persistence.ts";
|
|
12
|
+
export { StoricDO, StoricObject } from "./storic-object.ts";
|
|
13
|
+
export { sqlStorageLayer } from "./sql-storage-client.ts";
|
|
14
|
+
export { Store, Persistence, defineLens, SchemaRegistry, getTag, } from "@storic/core";
|
|
15
|
+
export type { AnyTaggedStruct, EntityRecord, Lens, StoreConfig, PersistenceShape, PersistenceRecord, StoredRecord, IndexSpec, InitSpec, QueryParams, } from "@storic/core";
|
|
16
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAGH,OAAO,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAC;AACxD,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAC5D,OAAO,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAC;AAG1D,OAAO,EACL,KAAK,EACL,WAAW,EACX,UAAU,EACV,cAAc,EACd,MAAM,GACP,MAAM,cAAc,CAAC;AACtB,YAAY,EACV,eAAe,EACf,YAAY,EACZ,IAAI,EACJ,WAAW,EACX,gBAAgB,EAChB,iBAAiB,EACjB,YAAY,EACZ,SAAS,EACT,QAAQ,EACR,WAAW,GACZ,MAAM,cAAc,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @storic/cloudflare
|
|
3
|
+
*
|
|
4
|
+
* Helper utilities for using Storic with Cloudflare Durable Objects.
|
|
5
|
+
*
|
|
6
|
+
* Provides:
|
|
7
|
+
* - `doStoragePersistence` — Persistence implementation backed by DO's SqlStorage
|
|
8
|
+
* - `StoricDO` — Base class for DOs with automatic Store setup
|
|
9
|
+
* - `sqlStorageLayer` — Low-level SqlClient adapter (for advanced use)
|
|
10
|
+
*/
|
|
11
|
+
// ─── Cloudflare-specific ────────────────────────────────────────────────────
|
|
12
|
+
export { doStoragePersistence } from "./persistence.js";
|
|
13
|
+
export { StoricDO, StoricObject } from "./storic-object.js";
|
|
14
|
+
export { sqlStorageLayer } from "./sql-storage-client.js";
|
|
15
|
+
// ─── Re-export core for convenience ─────────────────────────────────────────
|
|
16
|
+
export { Store, Persistence, defineLens, SchemaRegistry, getTag, } from "@storic/core";
|
|
17
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,+EAA+E;AAC/E,OAAO,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAC;AACxD,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAC5D,OAAO,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAC;AAE1D,+EAA+E;AAC/E,OAAO,EACL,KAAK,EACL,WAAW,EACX,UAAU,EACV,cAAc,EACd,MAAM,GACP,MAAM,cAAc,CAAC"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import * as Layer from "effect/Layer";
|
|
2
|
+
import { Persistence } from "@storic/core";
|
|
3
|
+
/**
|
|
4
|
+
* Persistence implementation backed by Cloudflare Durable Object's SqlStorage.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { doStoragePersistence } from "@storic/cloudflare";
|
|
9
|
+
*
|
|
10
|
+
* const persistenceLayer = doStoragePersistence(ctx.storage.sql);
|
|
11
|
+
* const storeLayer = Store.layer(config).pipe(Layer.provide(persistenceLayer));
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
export declare const doStoragePersistence: (storage: SqlStorage) => Layer.Layer<Persistence, never, never>;
|
|
15
|
+
//# sourceMappingURL=persistence.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"persistence.d.ts","sourceRoot":"","sources":["../src/persistence.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,KAAK,MAAM,cAAc,CAAC;AACtC,OAAO,EAAE,WAAW,EAAoB,MAAM,cAAc,CAAC;AAyB7D;;;;;;;;;;GAUG;AACH,eAAO,MAAM,oBAAoB,iEA8NhC,CAAC"}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as Layer from "effect/Layer";
|
|
3
|
+
import { Persistence, PersistenceError } from "@storic/core";
|
|
4
|
+
import { compileFilters } from "@storic/sql";
|
|
5
|
+
import { sqlStorageLayer } from "./sql-storage-client.js";
|
|
6
|
+
import { SqlClient } from "effect/unstable/sql/SqlClient";
|
|
7
|
+
/**
|
|
8
|
+
* Parse a database row into a StoredRecord.
|
|
9
|
+
*/
|
|
10
|
+
function rowToStoredRecord(row) {
|
|
11
|
+
return {
|
|
12
|
+
id: row.id,
|
|
13
|
+
type: row.type,
|
|
14
|
+
data: JSON.parse(row.data),
|
|
15
|
+
created_at: row.created_at,
|
|
16
|
+
updated_at: row.updated_at,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Persistence implementation backed by Cloudflare Durable Object's SqlStorage.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* import { doStoragePersistence } from "@storic/cloudflare";
|
|
25
|
+
*
|
|
26
|
+
* const persistenceLayer = doStoragePersistence(ctx.storage.sql);
|
|
27
|
+
* const storeLayer = Store.layer(config).pipe(Layer.provide(persistenceLayer));
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export const doStoragePersistence = (storage) => {
|
|
31
|
+
const sqlLayer = sqlStorageLayer(storage);
|
|
32
|
+
return Layer.effect(Persistence, Effect.gen(function* () {
|
|
33
|
+
const sql = yield* SqlClient;
|
|
34
|
+
const initialize = (spec) => Effect.gen(function* () {
|
|
35
|
+
yield* sql.unsafe(`
|
|
36
|
+
CREATE TABLE IF NOT EXISTS entities (
|
|
37
|
+
id TEXT PRIMARY KEY,
|
|
38
|
+
type TEXT NOT NULL,
|
|
39
|
+
data JSON NOT NULL,
|
|
40
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
41
|
+
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
42
|
+
)
|
|
43
|
+
`);
|
|
44
|
+
yield* sql.unsafe(`
|
|
45
|
+
CREATE INDEX IF NOT EXISTS idx_entities_type ON entities(type)
|
|
46
|
+
`);
|
|
47
|
+
const expectedIndexes = new Set();
|
|
48
|
+
for (const idx of spec.indexes) {
|
|
49
|
+
expectedIndexes.add(idx.name);
|
|
50
|
+
yield* sql.unsafe(`CREATE INDEX IF NOT EXISTS "${idx.name}" ` +
|
|
51
|
+
`ON entities(json_extract(data, '$.${idx.fieldPath}')) ` +
|
|
52
|
+
`WHERE type = '${idx.typeDiscriminator}'`);
|
|
53
|
+
}
|
|
54
|
+
const existingIndexes = yield* sql `SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'entities' AND name LIKE 'idx_%'`;
|
|
55
|
+
for (const { name } of existingIndexes) {
|
|
56
|
+
if (name !== "idx_entities_type" && !expectedIndexes.has(name)) {
|
|
57
|
+
yield* sql.unsafe(`DROP INDEX "${name}"`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}).pipe(Effect.mapError((error) => new PersistenceError({
|
|
61
|
+
message: `Initialization failed: ${error}`,
|
|
62
|
+
cause: error,
|
|
63
|
+
})));
|
|
64
|
+
const put = (record) => Effect.gen(function* () {
|
|
65
|
+
yield* sql `
|
|
66
|
+
INSERT INTO entities (id, type, data)
|
|
67
|
+
VALUES (${record.id}, ${record.type}, ${JSON.stringify(record.data)})
|
|
68
|
+
`;
|
|
69
|
+
const rows = yield* sql `SELECT * FROM entities WHERE id = ${record.id}`;
|
|
70
|
+
return rowToStoredRecord(rows[0]);
|
|
71
|
+
}).pipe(Effect.mapError((error) => new PersistenceError({
|
|
72
|
+
message: `Put failed for ${record.id}: ${error}`,
|
|
73
|
+
cause: error,
|
|
74
|
+
})));
|
|
75
|
+
const get = (id) => Effect.gen(function* () {
|
|
76
|
+
const rows = yield* sql `SELECT * FROM entities WHERE id = ${id}`;
|
|
77
|
+
if (rows.length === 0)
|
|
78
|
+
return null;
|
|
79
|
+
return rowToStoredRecord(rows[0]);
|
|
80
|
+
}).pipe(Effect.mapError((error) => new PersistenceError({
|
|
81
|
+
message: `Get failed for ${id}: ${error}`,
|
|
82
|
+
cause: error,
|
|
83
|
+
})));
|
|
84
|
+
const query = (params) => Effect.gen(function* () {
|
|
85
|
+
const compiled = compileFilters(params.filters);
|
|
86
|
+
let whereClause = `type IN (${params.types.map(() => "?").join(", ")})`;
|
|
87
|
+
const bindValues = [...params.types];
|
|
88
|
+
if (compiled) {
|
|
89
|
+
whereClause += ` AND ${compiled.sql}`;
|
|
90
|
+
bindValues.push(...compiled.values);
|
|
91
|
+
}
|
|
92
|
+
let stmt = `SELECT * FROM entities WHERE ${whereClause} ORDER BY created_at DESC`;
|
|
93
|
+
if (params.limit != null) {
|
|
94
|
+
stmt += ` LIMIT ${params.limit}`;
|
|
95
|
+
}
|
|
96
|
+
if (params.offset != null) {
|
|
97
|
+
stmt += ` OFFSET ${params.offset}`;
|
|
98
|
+
}
|
|
99
|
+
const rows = yield* sql.unsafe(stmt, bindValues);
|
|
100
|
+
return rows.map(rowToStoredRecord);
|
|
101
|
+
}).pipe(Effect.mapError((error) => new PersistenceError({
|
|
102
|
+
message: `Query failed: ${error}`,
|
|
103
|
+
cause: error,
|
|
104
|
+
})));
|
|
105
|
+
const update = (id, record) => Effect.gen(function* () {
|
|
106
|
+
yield* sql `
|
|
107
|
+
UPDATE entities
|
|
108
|
+
SET type = ${record.type},
|
|
109
|
+
data = ${JSON.stringify(record.data)},
|
|
110
|
+
updated_at = unixepoch()
|
|
111
|
+
WHERE id = ${id}
|
|
112
|
+
`;
|
|
113
|
+
const rows = yield* sql `SELECT * FROM entities WHERE id = ${id}`;
|
|
114
|
+
return rowToStoredRecord(rows[0]);
|
|
115
|
+
}).pipe(Effect.mapError((error) => new PersistenceError({
|
|
116
|
+
message: `Update failed for ${id}: ${error}`,
|
|
117
|
+
cause: error,
|
|
118
|
+
})));
|
|
119
|
+
const patch = (params) => Effect.gen(function* () {
|
|
120
|
+
if (params.patches.length === 0)
|
|
121
|
+
return 0;
|
|
122
|
+
let totalAffected = 0;
|
|
123
|
+
for (const entry of params.patches) {
|
|
124
|
+
const compiled = compileFilters(entry.filters);
|
|
125
|
+
let whereClause = `type = ?`;
|
|
126
|
+
const bindValues = [entry.type];
|
|
127
|
+
if (compiled) {
|
|
128
|
+
whereClause += ` AND ${compiled.sql}`;
|
|
129
|
+
bindValues.push(...compiled.values);
|
|
130
|
+
}
|
|
131
|
+
const result = yield* sql.unsafe(`UPDATE entities ` +
|
|
132
|
+
`SET data = json_patch(data, ?), updated_at = unixepoch() ` +
|
|
133
|
+
`WHERE ${whereClause} ` +
|
|
134
|
+
`RETURNING id`, [JSON.stringify(entry.patch), ...bindValues]);
|
|
135
|
+
totalAffected += result.length;
|
|
136
|
+
}
|
|
137
|
+
return totalAffected;
|
|
138
|
+
}).pipe(Effect.mapError((error) => new PersistenceError({
|
|
139
|
+
message: `Patch failed: ${error}`,
|
|
140
|
+
cause: error,
|
|
141
|
+
})));
|
|
142
|
+
const remove = (id) => Effect.gen(function* () {
|
|
143
|
+
yield* sql `DELETE FROM entities WHERE id = ${id}`;
|
|
144
|
+
}).pipe(Effect.mapError((error) => new PersistenceError({
|
|
145
|
+
message: `Remove failed for ${id}: ${error}`,
|
|
146
|
+
cause: error,
|
|
147
|
+
})));
|
|
148
|
+
return Persistence.of({
|
|
149
|
+
initialize,
|
|
150
|
+
put,
|
|
151
|
+
get,
|
|
152
|
+
query,
|
|
153
|
+
update,
|
|
154
|
+
patch,
|
|
155
|
+
remove,
|
|
156
|
+
});
|
|
157
|
+
})).pipe(Layer.provide(sqlLayer));
|
|
158
|
+
};
|
|
159
|
+
//# sourceMappingURL=persistence.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"persistence.js","sourceRoot":"","sources":["../src/persistence.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import * as Layer from "effect/Layer";
|
|
2
|
+
import * as Client from "effect/unstable/sql/SqlClient";
|
|
3
|
+
/**
|
|
4
|
+
* Create a `SqlClient` layer backed by a Durable Object's `SqlStorage`.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { DurableObject } from "cloudflare:workers";
|
|
9
|
+
* import { Store, sqlStorageLayer } from "@storic/cloudflare";
|
|
10
|
+
*
|
|
11
|
+
* export class MyDO extends DurableObject {
|
|
12
|
+
* private store: Store;
|
|
13
|
+
*
|
|
14
|
+
* constructor(ctx: DurableObjectState, env: Env) {
|
|
15
|
+
* super(ctx, env);
|
|
16
|
+
* const layer = Store.layer(config).pipe(
|
|
17
|
+
* Layer.provide(sqlStorageLayer(ctx.storage.sql))
|
|
18
|
+
* );
|
|
19
|
+
* // ...
|
|
20
|
+
* }
|
|
21
|
+
* }
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export declare const sqlStorageLayer: (sql: SqlStorage) => Layer.Layer<Client.SqlClient, never, never>;
|
|
25
|
+
//# sourceMappingURL=sql-storage-client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sql-storage-client.d.ts","sourceRoot":"","sources":["../src/sql-storage-client.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,KAAK,MAAM,cAAc,CAAC;AAOtC,OAAO,KAAK,MAAM,MAAM,+BAA+B,CAAC;AAoExD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,eAAe,kEAiC3B,CAAC"}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Effect SqlClient adapter for Cloudflare Durable Object's sync SqlStorage API.
|
|
3
|
+
*
|
|
4
|
+
* Wraps `ctx.storage.sql` (which is synchronous) into Effect's `SqlClient`
|
|
5
|
+
* interface so it can be used as the persistence layer for `Store.layer()`.
|
|
6
|
+
*/
|
|
7
|
+
import * as Effect from "effect/Effect";
|
|
8
|
+
import { identity } from "effect/Function";
|
|
9
|
+
import * as Layer from "effect/Layer";
|
|
10
|
+
import * as Scope from "effect/Scope";
|
|
11
|
+
import * as Semaphore from "effect/Semaphore";
|
|
12
|
+
import * as ServiceMap from "effect/ServiceMap";
|
|
13
|
+
import * as Fiber from "effect/Fiber";
|
|
14
|
+
import * as Stream from "effect/Stream";
|
|
15
|
+
import * as Reactivity from "effect/unstable/reactivity/Reactivity";
|
|
16
|
+
import * as Client from "effect/unstable/sql/SqlClient";
|
|
17
|
+
import { SqlError } from "effect/unstable/sql/SqlError";
|
|
18
|
+
import * as Statement from "effect/unstable/sql/Statement";
|
|
19
|
+
const ATTR_DB_SYSTEM_NAME = "db.system.name";
|
|
20
|
+
// ─── SqlStorage Connection ──────────────────────────────────────────────────
|
|
21
|
+
/**
|
|
22
|
+
* Create a Connection that delegates to the Durable Object's sync SqlStorage.
|
|
23
|
+
*
|
|
24
|
+
* `SqlStorage.exec(query, ...bindings)` returns a `SqlStorageCursor<T>` which
|
|
25
|
+
* is iterable and has `.toArray()`. The cursor returns rows as plain objects
|
|
26
|
+
* with column names as keys — the same shape Effect's SqlClient expects.
|
|
27
|
+
*/
|
|
28
|
+
function makeSqlStorageConnection(sql) {
|
|
29
|
+
const run = (query, params = []) => Effect.try({
|
|
30
|
+
try: () => sql.exec(query, ...params).toArray(),
|
|
31
|
+
catch: (cause) => new SqlError({ cause, message: "Failed to execute statement" }),
|
|
32
|
+
});
|
|
33
|
+
const runValues = (query, params = []) => Effect.try({
|
|
34
|
+
try: () => {
|
|
35
|
+
const cursor = sql.exec(query, ...params);
|
|
36
|
+
const columns = cursor.columnNames;
|
|
37
|
+
const rows = [];
|
|
38
|
+
for (const row of cursor) {
|
|
39
|
+
rows.push(columns.map((col) => row[col]));
|
|
40
|
+
}
|
|
41
|
+
return rows;
|
|
42
|
+
},
|
|
43
|
+
catch: (cause) => new SqlError({ cause, message: "Failed to execute statement" }),
|
|
44
|
+
});
|
|
45
|
+
return identity({
|
|
46
|
+
execute(query, params, transformRows) {
|
|
47
|
+
return transformRows
|
|
48
|
+
? Effect.map(run(query, params), transformRows)
|
|
49
|
+
: run(query, params);
|
|
50
|
+
},
|
|
51
|
+
executeRaw(query, params) {
|
|
52
|
+
return run(query, params);
|
|
53
|
+
},
|
|
54
|
+
executeValues(query, params) {
|
|
55
|
+
return runValues(query, params);
|
|
56
|
+
},
|
|
57
|
+
executeUnprepared(query, params, transformRows) {
|
|
58
|
+
return this.execute(query, params, transformRows);
|
|
59
|
+
},
|
|
60
|
+
executeStream(_query, _params) {
|
|
61
|
+
return Stream.die("executeStream not supported on Durable Object SqlStorage");
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
// ─── Layer ──────────────────────────────────────────────────────────────────
|
|
66
|
+
/**
|
|
67
|
+
* Create a `SqlClient` layer backed by a Durable Object's `SqlStorage`.
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* ```ts
|
|
71
|
+
* import { DurableObject } from "cloudflare:workers";
|
|
72
|
+
* import { Store, sqlStorageLayer } from "@storic/cloudflare";
|
|
73
|
+
*
|
|
74
|
+
* export class MyDO extends DurableObject {
|
|
75
|
+
* private store: Store;
|
|
76
|
+
*
|
|
77
|
+
* constructor(ctx: DurableObjectState, env: Env) {
|
|
78
|
+
* super(ctx, env);
|
|
79
|
+
* const layer = Store.layer(config).pipe(
|
|
80
|
+
* Layer.provide(sqlStorageLayer(ctx.storage.sql))
|
|
81
|
+
* );
|
|
82
|
+
* // ...
|
|
83
|
+
* }
|
|
84
|
+
* }
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
export const sqlStorageLayer = (sql) => {
|
|
88
|
+
const connection = makeSqlStorageConnection(sql);
|
|
89
|
+
const compiler = Statement.makeCompilerSqlite();
|
|
90
|
+
return Layer.effectServices(Effect.gen(function* () {
|
|
91
|
+
const semaphore = yield* Semaphore.make(1);
|
|
92
|
+
const acquirer = semaphore.withPermits(1)(Effect.succeed(connection));
|
|
93
|
+
const transactionAcquirer = Effect.uninterruptibleMask((restore) => {
|
|
94
|
+
const fiber = Fiber.getCurrent();
|
|
95
|
+
const scope = ServiceMap.getUnsafe(fiber.services, Scope.Scope);
|
|
96
|
+
return Effect.as(Effect.tap(restore(semaphore.take(1)), () => Scope.addFinalizer(scope, semaphore.release(1))), connection);
|
|
97
|
+
});
|
|
98
|
+
const client = yield* Client.make({
|
|
99
|
+
acquirer,
|
|
100
|
+
compiler,
|
|
101
|
+
transactionAcquirer,
|
|
102
|
+
spanAttributes: [[ATTR_DB_SYSTEM_NAME, "sqlite"]],
|
|
103
|
+
});
|
|
104
|
+
return ServiceMap.make(Client.SqlClient, client);
|
|
105
|
+
})).pipe(Layer.provide(Reactivity.layer));
|
|
106
|
+
};
|
|
107
|
+
//# sourceMappingURL=sql-storage-client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sql-storage-client.js","sourceRoot":"","sources":["../src/sql-storage-client.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { DurableObject } from "cloudflare:workers";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import { Store } from "@storic/core";
|
|
4
|
+
import type { StoreConfig } from "@storic/core";
|
|
5
|
+
/**
|
|
6
|
+
* Generic Durable Object that provides a Persistence layer.
|
|
7
|
+
*
|
|
8
|
+
* This is the "dumb store" — it knows nothing about schemas or lenses.
|
|
9
|
+
* All schema validation and lens transforms happen caller-side in the
|
|
10
|
+
* Store layer, which is composed on top of the Persistence this DO provides.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* import { DurableObject } from "cloudflare:workers";
|
|
15
|
+
* import { StoricDO, Store } from "@storic/cloudflare";
|
|
16
|
+
* import type { StoreConfig } from "@storic/cloudflare";
|
|
17
|
+
*
|
|
18
|
+
* const config: StoreConfig = { schemas: [PersonV1, PersonV2], lenses: [PersonV1toV2] };
|
|
19
|
+
*
|
|
20
|
+
* export class MyDO extends StoricDO<Env> {
|
|
21
|
+
* get config(): StoreConfig {
|
|
22
|
+
* return config;
|
|
23
|
+
* }
|
|
24
|
+
*
|
|
25
|
+
* async fetch(request: Request) {
|
|
26
|
+
* const entities = await this.run(
|
|
27
|
+
* Store.use((store) => store.loadEntities(PersonV2))
|
|
28
|
+
* );
|
|
29
|
+
* return Response.json(entities);
|
|
30
|
+
* }
|
|
31
|
+
* }
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export declare abstract class StoricDO<Env = unknown, Props = {}> extends DurableObject<Env, Props> {
|
|
35
|
+
/**
|
|
36
|
+
* Override this getter to provide the Storic configuration.
|
|
37
|
+
* Schemas and lenses are defined here, outside the DO's storage concerns.
|
|
38
|
+
*/
|
|
39
|
+
abstract get config(): StoreConfig;
|
|
40
|
+
private _runtime;
|
|
41
|
+
constructor(ctx: DurableObjectState, env: Env);
|
|
42
|
+
/**
|
|
43
|
+
* Run an Effect program with `Store` available in the context.
|
|
44
|
+
*/
|
|
45
|
+
protected run<A, E>(effect: Effect.Effect<A, E, Store>): Promise<A>;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* @deprecated Use `StoricDO` instead. This is an alias for backward compatibility.
|
|
49
|
+
*/
|
|
50
|
+
export declare const StoricObject: typeof StoricDO;
|
|
51
|
+
//# sourceMappingURL=storic-object.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"storic-object.d.ts","sourceRoot":"","sources":["../src/storic-object.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACnD,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAGxC,OAAO,EAAE,KAAK,EAAe,MAAM,cAAc,CAAC;AAClD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAGhD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,8BAAsB,QAAQ,CAC5B,GAAG,GAAG,OAAO,EACb,KAAK,GAAG,EAAE,CACV,SAAQ,aAAa,CAAC,GAAG,EAAE,KAAK,CAAC;IACjC;;;OAGG;IACH,QAAQ,KAAK,MAAM,IAAI,WAAW,CAAC;IAEnC,OAAO,CAAC,QAAQ,CAA+C;IAE/D,YAAY,GAAG,EAAE,kBAAkB,EAAE,GAAG,EAAE,GAAG,EAkB5C;IAED;;OAEG;IACH,SAAS,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,KAAK,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAElE;CACF;AAED;;GAEG;AACH,eAAO,MAAM,YAAY,iBAAW,CAAC"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { DurableObject } from "cloudflare:workers";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as Layer from "effect/Layer";
|
|
4
|
+
import * as ManagedRuntime from "effect/ManagedRuntime";
|
|
5
|
+
import { Store } from "@storic/core";
|
|
6
|
+
import { doStoragePersistence } from "./persistence.js";
|
|
7
|
+
/**
|
|
8
|
+
* Generic Durable Object that provides a Persistence layer.
|
|
9
|
+
*
|
|
10
|
+
* This is the "dumb store" — it knows nothing about schemas or lenses.
|
|
11
|
+
* All schema validation and lens transforms happen caller-side in the
|
|
12
|
+
* Store layer, which is composed on top of the Persistence this DO provides.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* import { DurableObject } from "cloudflare:workers";
|
|
17
|
+
* import { StoricDO, Store } from "@storic/cloudflare";
|
|
18
|
+
* import type { StoreConfig } from "@storic/cloudflare";
|
|
19
|
+
*
|
|
20
|
+
* const config: StoreConfig = { schemas: [PersonV1, PersonV2], lenses: [PersonV1toV2] };
|
|
21
|
+
*
|
|
22
|
+
* export class MyDO extends StoricDO<Env> {
|
|
23
|
+
* get config(): StoreConfig {
|
|
24
|
+
* return config;
|
|
25
|
+
* }
|
|
26
|
+
*
|
|
27
|
+
* async fetch(request: Request) {
|
|
28
|
+
* const entities = await this.run(
|
|
29
|
+
* Store.use((store) => store.loadEntities(PersonV2))
|
|
30
|
+
* );
|
|
31
|
+
* return Response.json(entities);
|
|
32
|
+
* }
|
|
33
|
+
* }
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export class StoricDO extends DurableObject {
|
|
37
|
+
_runtime;
|
|
38
|
+
constructor(ctx, env) {
|
|
39
|
+
super(ctx, env);
|
|
40
|
+
this.ctx.blockConcurrencyWhile(async () => {
|
|
41
|
+
const persistenceLayer = doStoragePersistence(this.ctx.storage.sql);
|
|
42
|
+
// Store.layer handles schema registry, index computation, and
|
|
43
|
+
// delegates storage to the Persistence backend.
|
|
44
|
+
const storeLayer = Store.layer(this.config).pipe(Layer.provide(persistenceLayer), Layer.orDie);
|
|
45
|
+
this._runtime = ManagedRuntime.make(storeLayer);
|
|
46
|
+
// Force initialization inside blockConcurrencyWhile
|
|
47
|
+
await this._runtime.runPromise(Effect.void);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Run an Effect program with `Store` available in the context.
|
|
52
|
+
*/
|
|
53
|
+
run(effect) {
|
|
54
|
+
return this._runtime.runPromise(effect);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* @deprecated Use `StoricDO` instead. This is an alias for backward compatibility.
|
|
59
|
+
*/
|
|
60
|
+
export const StoricObject = StoricDO;
|
|
61
|
+
//# sourceMappingURL=storic-object.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"storic-object.js","sourceRoot":"","sources":["../src/storic-object.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACnD,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,KAAK,MAAM,cAAc,CAAC;AACtC,OAAO,KAAK,cAAc,MAAM,uBAAuB,CAAC;AACxD,OAAO,EAAE,KAAK,EAAe,MAAM,cAAc,CAAC;AAElD,OAAO,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAC;AAExD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,MAAM,OAAgB,QAGpB,SAAQ,aAAyB;IAOzB,QAAQ,CAA+C;IAE/D,YAAY,GAAuB,EAAE,GAAQ;QAC3C,KAAK,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;QAEhB,IAAI,CAAC,GAAG,CAAC,qBAAqB,CAAC,KAAK,IAAI,EAAE;YACxC,MAAM,gBAAgB,GAAG,oBAAoB,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;YAEpE,8DAA8D;YAC9D,gDAAgD;YAChD,MAAM,UAAU,GAAG,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,IAAI,CAC9C,KAAK,CAAC,OAAO,CAAC,gBAAgB,CAAC,EAC/B,KAAK,CAAC,KAAK,CACZ,CAAC;YAEF,IAAI,CAAC,QAAQ,GAAG,cAAc,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;YAEhD,oDAAoD;YACpD,MAAM,IAAI,CAAC,QAAQ,CAAC,UAAU,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;QAC9C,CAAC,CAAC,CAAC;IACL,CAAC;IAED;;OAEG;IACO,GAAG,CAAO,MAAkC;QACpD,OAAO,IAAI,CAAC,QAAQ,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC;IAC1C,CAAC;CACF;AAED;;GAEG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG,QAAQ,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@storic/cloudflare",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"import": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"src"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsgo --build",
|
|
19
|
+
"check": "tsgo --noEmit",
|
|
20
|
+
"test": "bun test test/",
|
|
21
|
+
"test:e2e": "bun test e2e/e2e.test.ts --timeout 60000"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@storic/core": "workspace:*",
|
|
25
|
+
"@storic/sql": "workspace:*",
|
|
26
|
+
"effect": "4.0.0-beta.29"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@cloudflare/workers-types": "^4.20260307.1",
|
|
30
|
+
"@types/bun": "^1.3.10",
|
|
31
|
+
"@types/node": "^22.0.0",
|
|
32
|
+
"@typescript/native-preview": "catalog:",
|
|
33
|
+
"wrangler": "^4.71.0"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @storic/cloudflare
|
|
3
|
+
*
|
|
4
|
+
* Helper utilities for using Storic with Cloudflare Durable Objects.
|
|
5
|
+
*
|
|
6
|
+
* Provides:
|
|
7
|
+
* - `doStoragePersistence` — Persistence implementation backed by DO's SqlStorage
|
|
8
|
+
* - `StoricDO` — Base class for DOs with automatic Store setup
|
|
9
|
+
* - `sqlStorageLayer` — Low-level SqlClient adapter (for advanced use)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// ─── Cloudflare-specific ────────────────────────────────────────────────────
|
|
13
|
+
export { doStoragePersistence } from "./persistence.ts";
|
|
14
|
+
export { StoricDO, StoricObject } from "./storic-object.ts";
|
|
15
|
+
export { sqlStorageLayer } from "./sql-storage-client.ts";
|
|
16
|
+
|
|
17
|
+
// ─── Re-export core for convenience ─────────────────────────────────────────
|
|
18
|
+
export {
|
|
19
|
+
Store,
|
|
20
|
+
Persistence,
|
|
21
|
+
defineLens,
|
|
22
|
+
SchemaRegistry,
|
|
23
|
+
getTag,
|
|
24
|
+
} from "@storic/core";
|
|
25
|
+
export type {
|
|
26
|
+
AnyTaggedStruct,
|
|
27
|
+
EntityRecord,
|
|
28
|
+
Lens,
|
|
29
|
+
StoreConfig,
|
|
30
|
+
PersistenceShape,
|
|
31
|
+
PersistenceRecord,
|
|
32
|
+
StoredRecord,
|
|
33
|
+
IndexSpec,
|
|
34
|
+
InitSpec,
|
|
35
|
+
QueryParams,
|
|
36
|
+
} from "@storic/core";
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as Layer from "effect/Layer";
|
|
3
|
+
import { Persistence, PersistenceError } from "@storic/core";
|
|
4
|
+
import { compileFilters } from "@storic/sql";
|
|
5
|
+
import type {
|
|
6
|
+
InitSpec,
|
|
7
|
+
PatchParams,
|
|
8
|
+
PersistenceRecord,
|
|
9
|
+
QueryParams,
|
|
10
|
+
StoredRecord,
|
|
11
|
+
} from "@storic/core";
|
|
12
|
+
import { sqlStorageLayer } from "./sql-storage-client.ts";
|
|
13
|
+
import { SqlClient } from "effect/unstable/sql/SqlClient";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Parse a database row into a StoredRecord.
|
|
17
|
+
*/
|
|
18
|
+
function rowToStoredRecord(row: Record<string, unknown>): StoredRecord {
|
|
19
|
+
return {
|
|
20
|
+
id: row.id as string,
|
|
21
|
+
type: row.type as string,
|
|
22
|
+
data: JSON.parse(row.data as string) as Record<string, unknown>,
|
|
23
|
+
created_at: row.created_at as number,
|
|
24
|
+
updated_at: row.updated_at as number,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Persistence implementation backed by Cloudflare Durable Object's SqlStorage.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```ts
|
|
33
|
+
* import { doStoragePersistence } from "@storic/cloudflare";
|
|
34
|
+
*
|
|
35
|
+
* const persistenceLayer = doStoragePersistence(ctx.storage.sql);
|
|
36
|
+
* const storeLayer = Store.layer(config).pipe(Layer.provide(persistenceLayer));
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export const doStoragePersistence = (
|
|
40
|
+
storage: SqlStorage,
|
|
41
|
+
): Layer.Layer<Persistence> => {
|
|
42
|
+
const sqlLayer = sqlStorageLayer(storage);
|
|
43
|
+
|
|
44
|
+
return Layer.effect(
|
|
45
|
+
Persistence,
|
|
46
|
+
Effect.gen(function* () {
|
|
47
|
+
const sql = yield* SqlClient;
|
|
48
|
+
|
|
49
|
+
const initialize = (spec: InitSpec) =>
|
|
50
|
+
Effect.gen(function* () {
|
|
51
|
+
yield* sql.unsafe(`
|
|
52
|
+
CREATE TABLE IF NOT EXISTS entities (
|
|
53
|
+
id TEXT PRIMARY KEY,
|
|
54
|
+
type TEXT NOT NULL,
|
|
55
|
+
data JSON NOT NULL,
|
|
56
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
57
|
+
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
58
|
+
)
|
|
59
|
+
`);
|
|
60
|
+
yield* sql.unsafe(`
|
|
61
|
+
CREATE INDEX IF NOT EXISTS idx_entities_type ON entities(type)
|
|
62
|
+
`);
|
|
63
|
+
|
|
64
|
+
const expectedIndexes = new Set<string>();
|
|
65
|
+
|
|
66
|
+
for (const idx of spec.indexes) {
|
|
67
|
+
expectedIndexes.add(idx.name);
|
|
68
|
+
yield* sql.unsafe(
|
|
69
|
+
`CREATE INDEX IF NOT EXISTS "${idx.name}" ` +
|
|
70
|
+
`ON entities(json_extract(data, '$.${idx.fieldPath}')) ` +
|
|
71
|
+
`WHERE type = '${idx.typeDiscriminator}'`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const existingIndexes = yield* sql<{
|
|
76
|
+
name: string;
|
|
77
|
+
}>`SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'entities' AND name LIKE 'idx_%'`;
|
|
78
|
+
|
|
79
|
+
for (const { name } of existingIndexes) {
|
|
80
|
+
if (name !== "idx_entities_type" && !expectedIndexes.has(name)) {
|
|
81
|
+
yield* sql.unsafe(`DROP INDEX "${name}"`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}).pipe(
|
|
85
|
+
Effect.mapError(
|
|
86
|
+
(error) =>
|
|
87
|
+
new PersistenceError({
|
|
88
|
+
message: `Initialization failed: ${error}`,
|
|
89
|
+
cause: error,
|
|
90
|
+
}),
|
|
91
|
+
),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const put = (record: PersistenceRecord) =>
|
|
95
|
+
Effect.gen(function* () {
|
|
96
|
+
yield* sql`
|
|
97
|
+
INSERT INTO entities (id, type, data)
|
|
98
|
+
VALUES (${record.id}, ${record.type}, ${JSON.stringify(record.data)})
|
|
99
|
+
`;
|
|
100
|
+
|
|
101
|
+
const rows = yield* sql<
|
|
102
|
+
Record<string, unknown>
|
|
103
|
+
>`SELECT * FROM entities WHERE id = ${record.id}`;
|
|
104
|
+
|
|
105
|
+
return rowToStoredRecord(rows[0]);
|
|
106
|
+
}).pipe(
|
|
107
|
+
Effect.mapError(
|
|
108
|
+
(error) =>
|
|
109
|
+
new PersistenceError({
|
|
110
|
+
message: `Put failed for ${record.id}: ${error}`,
|
|
111
|
+
cause: error,
|
|
112
|
+
}),
|
|
113
|
+
),
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const get = (id: string) =>
|
|
117
|
+
Effect.gen(function* () {
|
|
118
|
+
const rows = yield* sql<
|
|
119
|
+
Record<string, unknown>
|
|
120
|
+
>`SELECT * FROM entities WHERE id = ${id}`;
|
|
121
|
+
|
|
122
|
+
if (rows.length === 0) return null;
|
|
123
|
+
return rowToStoredRecord(rows[0]);
|
|
124
|
+
}).pipe(
|
|
125
|
+
Effect.mapError(
|
|
126
|
+
(error) =>
|
|
127
|
+
new PersistenceError({
|
|
128
|
+
message: `Get failed for ${id}: ${error}`,
|
|
129
|
+
cause: error,
|
|
130
|
+
}),
|
|
131
|
+
),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const query = (params: QueryParams) =>
|
|
135
|
+
Effect.gen(function* () {
|
|
136
|
+
const compiled = compileFilters(params.filters);
|
|
137
|
+
|
|
138
|
+
let whereClause = `type IN (${params.types.map(() => "?").join(", ")})`;
|
|
139
|
+
const bindValues: unknown[] = [...params.types];
|
|
140
|
+
|
|
141
|
+
if (compiled) {
|
|
142
|
+
whereClause += ` AND ${compiled.sql}`;
|
|
143
|
+
bindValues.push(...compiled.values);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let stmt = `SELECT * FROM entities WHERE ${whereClause} ORDER BY created_at DESC`;
|
|
147
|
+
|
|
148
|
+
if (params.limit != null) {
|
|
149
|
+
stmt += ` LIMIT ${params.limit}`;
|
|
150
|
+
}
|
|
151
|
+
if (params.offset != null) {
|
|
152
|
+
stmt += ` OFFSET ${params.offset}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const rows = yield* sql.unsafe<Record<string, unknown>>(
|
|
156
|
+
stmt,
|
|
157
|
+
bindValues,
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
return rows.map(rowToStoredRecord);
|
|
161
|
+
}).pipe(
|
|
162
|
+
Effect.mapError(
|
|
163
|
+
(error) =>
|
|
164
|
+
new PersistenceError({
|
|
165
|
+
message: `Query failed: ${error}`,
|
|
166
|
+
cause: error,
|
|
167
|
+
}),
|
|
168
|
+
),
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const update = (
|
|
172
|
+
id: string,
|
|
173
|
+
record: { readonly type: string; readonly data: Record<string, unknown> },
|
|
174
|
+
) =>
|
|
175
|
+
Effect.gen(function* () {
|
|
176
|
+
yield* sql`
|
|
177
|
+
UPDATE entities
|
|
178
|
+
SET type = ${record.type},
|
|
179
|
+
data = ${JSON.stringify(record.data)},
|
|
180
|
+
updated_at = unixepoch()
|
|
181
|
+
WHERE id = ${id}
|
|
182
|
+
`;
|
|
183
|
+
|
|
184
|
+
const rows = yield* sql<
|
|
185
|
+
Record<string, unknown>
|
|
186
|
+
>`SELECT * FROM entities WHERE id = ${id}`;
|
|
187
|
+
|
|
188
|
+
return rowToStoredRecord(rows[0]);
|
|
189
|
+
}).pipe(
|
|
190
|
+
Effect.mapError(
|
|
191
|
+
(error) =>
|
|
192
|
+
new PersistenceError({
|
|
193
|
+
message: `Update failed for ${id}: ${error}`,
|
|
194
|
+
cause: error,
|
|
195
|
+
}),
|
|
196
|
+
),
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const patch = (params: PatchParams) =>
|
|
200
|
+
Effect.gen(function* () {
|
|
201
|
+
if (params.patches.length === 0) return 0;
|
|
202
|
+
|
|
203
|
+
let totalAffected = 0;
|
|
204
|
+
|
|
205
|
+
for (const entry of params.patches) {
|
|
206
|
+
const compiled = compileFilters(entry.filters);
|
|
207
|
+
|
|
208
|
+
let whereClause = `type = ?`;
|
|
209
|
+
const bindValues: unknown[] = [entry.type];
|
|
210
|
+
|
|
211
|
+
if (compiled) {
|
|
212
|
+
whereClause += ` AND ${compiled.sql}`;
|
|
213
|
+
bindValues.push(...compiled.values);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const result = yield* sql.unsafe<Record<string, unknown>>(
|
|
217
|
+
`UPDATE entities ` +
|
|
218
|
+
`SET data = json_patch(data, ?), updated_at = unixepoch() ` +
|
|
219
|
+
`WHERE ${whereClause} ` +
|
|
220
|
+
`RETURNING id`,
|
|
221
|
+
[JSON.stringify(entry.patch), ...bindValues],
|
|
222
|
+
);
|
|
223
|
+
totalAffected += result.length;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return totalAffected;
|
|
227
|
+
}).pipe(
|
|
228
|
+
Effect.mapError(
|
|
229
|
+
(error) =>
|
|
230
|
+
new PersistenceError({
|
|
231
|
+
message: `Patch failed: ${error}`,
|
|
232
|
+
cause: error,
|
|
233
|
+
}),
|
|
234
|
+
),
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
const remove = (id: string) =>
|
|
238
|
+
Effect.gen(function* () {
|
|
239
|
+
yield* sql`DELETE FROM entities WHERE id = ${id}`;
|
|
240
|
+
}).pipe(
|
|
241
|
+
Effect.mapError(
|
|
242
|
+
(error) =>
|
|
243
|
+
new PersistenceError({
|
|
244
|
+
message: `Remove failed for ${id}: ${error}`,
|
|
245
|
+
cause: error,
|
|
246
|
+
}),
|
|
247
|
+
),
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
return Persistence.of({
|
|
251
|
+
initialize,
|
|
252
|
+
put,
|
|
253
|
+
get,
|
|
254
|
+
query,
|
|
255
|
+
update,
|
|
256
|
+
patch,
|
|
257
|
+
remove,
|
|
258
|
+
});
|
|
259
|
+
}),
|
|
260
|
+
).pipe(Layer.provide(sqlLayer));
|
|
261
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Effect SqlClient adapter for Cloudflare Durable Object's sync SqlStorage API.
|
|
3
|
+
*
|
|
4
|
+
* Wraps `ctx.storage.sql` (which is synchronous) into Effect's `SqlClient`
|
|
5
|
+
* interface so it can be used as the persistence layer for `Store.layer()`.
|
|
6
|
+
*/
|
|
7
|
+
import * as Effect from "effect/Effect";
|
|
8
|
+
import { identity } from "effect/Function";
|
|
9
|
+
import * as Layer from "effect/Layer";
|
|
10
|
+
import * as Scope from "effect/Scope";
|
|
11
|
+
import * as Semaphore from "effect/Semaphore";
|
|
12
|
+
import * as ServiceMap from "effect/ServiceMap";
|
|
13
|
+
import * as Fiber from "effect/Fiber";
|
|
14
|
+
import * as Stream from "effect/Stream";
|
|
15
|
+
import * as Reactivity from "effect/unstable/reactivity/Reactivity";
|
|
16
|
+
import * as Client from "effect/unstable/sql/SqlClient";
|
|
17
|
+
import type { Connection } from "effect/unstable/sql/SqlConnection";
|
|
18
|
+
import { SqlError } from "effect/unstable/sql/SqlError";
|
|
19
|
+
import * as Statement from "effect/unstable/sql/Statement";
|
|
20
|
+
|
|
21
|
+
const ATTR_DB_SYSTEM_NAME = "db.system.name";
|
|
22
|
+
|
|
23
|
+
// ─── SqlStorage Connection ──────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Create a Connection that delegates to the Durable Object's sync SqlStorage.
|
|
27
|
+
*
|
|
28
|
+
* `SqlStorage.exec(query, ...bindings)` returns a `SqlStorageCursor<T>` which
|
|
29
|
+
* is iterable and has `.toArray()`. The cursor returns rows as plain objects
|
|
30
|
+
* with column names as keys — the same shape Effect's SqlClient expects.
|
|
31
|
+
*/
|
|
32
|
+
function makeSqlStorageConnection(sql: SqlStorage): Connection {
|
|
33
|
+
const run = (
|
|
34
|
+
query: string,
|
|
35
|
+
params: ReadonlyArray<unknown> = [],
|
|
36
|
+
): Effect.Effect<Array<any>, SqlError> =>
|
|
37
|
+
Effect.try({
|
|
38
|
+
try: () => sql.exec(query, ...params).toArray(),
|
|
39
|
+
catch: (cause) =>
|
|
40
|
+
new SqlError({ cause, message: "Failed to execute statement" }),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const runValues = (
|
|
44
|
+
query: string,
|
|
45
|
+
params: ReadonlyArray<unknown> = [],
|
|
46
|
+
): Effect.Effect<Array<Array<unknown>>, SqlError> =>
|
|
47
|
+
Effect.try({
|
|
48
|
+
try: () => {
|
|
49
|
+
const cursor = sql.exec(query, ...params);
|
|
50
|
+
const columns = cursor.columnNames;
|
|
51
|
+
const rows: Array<Array<unknown>> = [];
|
|
52
|
+
for (const row of cursor) {
|
|
53
|
+
rows.push(columns.map((col) => (row as Record<string, unknown>)[col]));
|
|
54
|
+
}
|
|
55
|
+
return rows;
|
|
56
|
+
},
|
|
57
|
+
catch: (cause) =>
|
|
58
|
+
new SqlError({ cause, message: "Failed to execute statement" }),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return identity<Connection>({
|
|
62
|
+
execute(query, params, transformRows) {
|
|
63
|
+
return transformRows
|
|
64
|
+
? Effect.map(run(query, params), transformRows)
|
|
65
|
+
: run(query, params);
|
|
66
|
+
},
|
|
67
|
+
executeRaw(query, params) {
|
|
68
|
+
return run(query, params);
|
|
69
|
+
},
|
|
70
|
+
executeValues(query, params) {
|
|
71
|
+
return runValues(query, params);
|
|
72
|
+
},
|
|
73
|
+
executeUnprepared(query, params, transformRows) {
|
|
74
|
+
return this.execute(query, params, transformRows);
|
|
75
|
+
},
|
|
76
|
+
executeStream(_query, _params) {
|
|
77
|
+
return Stream.die("executeStream not supported on Durable Object SqlStorage");
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ─── Layer ──────────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Create a `SqlClient` layer backed by a Durable Object's `SqlStorage`.
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* ```ts
|
|
89
|
+
* import { DurableObject } from "cloudflare:workers";
|
|
90
|
+
* import { Store, sqlStorageLayer } from "@storic/cloudflare";
|
|
91
|
+
*
|
|
92
|
+
* export class MyDO extends DurableObject {
|
|
93
|
+
* private store: Store;
|
|
94
|
+
*
|
|
95
|
+
* constructor(ctx: DurableObjectState, env: Env) {
|
|
96
|
+
* super(ctx, env);
|
|
97
|
+
* const layer = Store.layer(config).pipe(
|
|
98
|
+
* Layer.provide(sqlStorageLayer(ctx.storage.sql))
|
|
99
|
+
* );
|
|
100
|
+
* // ...
|
|
101
|
+
* }
|
|
102
|
+
* }
|
|
103
|
+
* ```
|
|
104
|
+
*/
|
|
105
|
+
export const sqlStorageLayer = (
|
|
106
|
+
sql: SqlStorage,
|
|
107
|
+
): Layer.Layer<Client.SqlClient> => {
|
|
108
|
+
const connection = makeSqlStorageConnection(sql);
|
|
109
|
+
const compiler = Statement.makeCompilerSqlite();
|
|
110
|
+
|
|
111
|
+
return Layer.effectServices(
|
|
112
|
+
Effect.gen(function* () {
|
|
113
|
+
const semaphore = yield* Semaphore.make(1);
|
|
114
|
+
|
|
115
|
+
const acquirer = semaphore.withPermits(1)(Effect.succeed(connection));
|
|
116
|
+
const transactionAcquirer = Effect.uninterruptibleMask((restore) => {
|
|
117
|
+
const fiber = Fiber.getCurrent()!;
|
|
118
|
+
const scope = ServiceMap.getUnsafe(fiber.services, Scope.Scope);
|
|
119
|
+
return Effect.as(
|
|
120
|
+
Effect.tap(
|
|
121
|
+
restore(semaphore.take(1)),
|
|
122
|
+
() => Scope.addFinalizer(scope, semaphore.release(1)),
|
|
123
|
+
),
|
|
124
|
+
connection,
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const client = yield* Client.make({
|
|
129
|
+
acquirer,
|
|
130
|
+
compiler,
|
|
131
|
+
transactionAcquirer,
|
|
132
|
+
spanAttributes: [[ATTR_DB_SYSTEM_NAME, "sqlite"]],
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
return ServiceMap.make(Client.SqlClient, client);
|
|
136
|
+
}),
|
|
137
|
+
).pipe(Layer.provide(Reactivity.layer));
|
|
138
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { DurableObject } from "cloudflare:workers";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as Layer from "effect/Layer";
|
|
4
|
+
import * as ManagedRuntime from "effect/ManagedRuntime";
|
|
5
|
+
import { Store, Persistence } from "@storic/core";
|
|
6
|
+
import type { StoreConfig } from "@storic/core";
|
|
7
|
+
import { doStoragePersistence } from "./persistence.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Generic Durable Object that provides a Persistence layer.
|
|
11
|
+
*
|
|
12
|
+
* This is the "dumb store" — it knows nothing about schemas or lenses.
|
|
13
|
+
* All schema validation and lens transforms happen caller-side in the
|
|
14
|
+
* Store layer, which is composed on top of the Persistence this DO provides.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* import { DurableObject } from "cloudflare:workers";
|
|
19
|
+
* import { StoricDO, Store } from "@storic/cloudflare";
|
|
20
|
+
* import type { StoreConfig } from "@storic/cloudflare";
|
|
21
|
+
*
|
|
22
|
+
* const config: StoreConfig = { schemas: [PersonV1, PersonV2], lenses: [PersonV1toV2] };
|
|
23
|
+
*
|
|
24
|
+
* export class MyDO extends StoricDO<Env> {
|
|
25
|
+
* get config(): StoreConfig {
|
|
26
|
+
* return config;
|
|
27
|
+
* }
|
|
28
|
+
*
|
|
29
|
+
* async fetch(request: Request) {
|
|
30
|
+
* const entities = await this.run(
|
|
31
|
+
* Store.use((store) => store.loadEntities(PersonV2))
|
|
32
|
+
* );
|
|
33
|
+
* return Response.json(entities);
|
|
34
|
+
* }
|
|
35
|
+
* }
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export abstract class StoricDO<
|
|
39
|
+
Env = unknown,
|
|
40
|
+
Props = {},
|
|
41
|
+
> extends DurableObject<Env, Props> {
|
|
42
|
+
/**
|
|
43
|
+
* Override this getter to provide the Storic configuration.
|
|
44
|
+
* Schemas and lenses are defined here, outside the DO's storage concerns.
|
|
45
|
+
*/
|
|
46
|
+
abstract get config(): StoreConfig;
|
|
47
|
+
|
|
48
|
+
private _runtime!: ManagedRuntime.ManagedRuntime<Store, never>;
|
|
49
|
+
|
|
50
|
+
constructor(ctx: DurableObjectState, env: Env) {
|
|
51
|
+
super(ctx, env);
|
|
52
|
+
|
|
53
|
+
this.ctx.blockConcurrencyWhile(async () => {
|
|
54
|
+
const persistenceLayer = doStoragePersistence(this.ctx.storage.sql);
|
|
55
|
+
|
|
56
|
+
// Store.layer handles schema registry, index computation, and
|
|
57
|
+
// delegates storage to the Persistence backend.
|
|
58
|
+
const storeLayer = Store.layer(this.config).pipe(
|
|
59
|
+
Layer.provide(persistenceLayer),
|
|
60
|
+
Layer.orDie,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
this._runtime = ManagedRuntime.make(storeLayer);
|
|
64
|
+
|
|
65
|
+
// Force initialization inside blockConcurrencyWhile
|
|
66
|
+
await this._runtime.runPromise(Effect.void);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Run an Effect program with `Store` available in the context.
|
|
72
|
+
*/
|
|
73
|
+
protected run<A, E>(effect: Effect.Effect<A, E, Store>): Promise<A> {
|
|
74
|
+
return this._runtime.runPromise(effect);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @deprecated Use `StoricDO` instead. This is an alias for backward compatibility.
|
|
80
|
+
*/
|
|
81
|
+
export const StoricObject = StoricDO;
|