@super-line/collections-cloudflare 0.0.0-stage → 0.2.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/CHANGELOG.md +26 -0
- package/LICENSE +21 -0
- package/README.md +104 -2
- package/dist/contract.d.ts +66 -0
- package/dist/contract.js +19 -0
- package/dist/index.d.ts +147 -0
- package/dist/index.js +667 -0
- package/package.json +54 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Generated from conventional commits by [git-cliff](https://git-cliff.org) —
|
|
4
|
+
run `pnpm changelog` rather than editing by hand.
|
|
5
|
+
|
|
6
|
+
## [collections-cloudflare-v0.2.0] — 2026-10-20
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
- **collections-cloudflare:** A foreign object's SQLite as a live collection ([a9579af])
|
|
11
|
+
|
|
12
|
+
### Bug Fixes
|
|
13
|
+
|
|
14
|
+
- **collections-cloudflare:** ReadOnly catches writes in every statement ([e391e8e])
|
|
15
|
+
|
|
16
|
+
## [collections-cloudflare-v0.1.0] — 2026-10-15
|
|
17
|
+
|
|
18
|
+
### Features
|
|
19
|
+
|
|
20
|
+
- **collections-cloudflare:** Row collections on Durable Object SQLite ([6b1f3eb])
|
|
21
|
+
|
|
22
|
+
### Documentation
|
|
23
|
+
|
|
24
|
+
- Teach hosting a node on Cloudflare Durable Objects ([a893f83])
|
|
25
|
+
|
|
26
|
+
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mert
|
|
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.
|
package/README.md
CHANGED
|
@@ -1,3 +1,105 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @super-line/collections-cloudflare
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[**super-line**](https://super-line.dogar.biz/) [row collections](https://super-line.dogar.biz/collections/row-collections) stored in a Cloudflare **Durable Object's** own SQLite — the object that is the node (see [`@super-line/transport-cloudflare`](https://github.com/mertdogar/super-line/tree/main/packages/transport-cloudflare)). Each collection gets its own typed table, laid out exactly as [`@super-line/collections-sqlite`](https://github.com/mertdogar/super-line/tree/main/packages/collections-sqlite) does, with the same IR→SQL snapshot pushdown, and one `transactionSync` spans every collection, so a cross-collection batch stays atomic.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pnpm add @super-line/core @super-line/collections-cloudflare
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { createSuperLineServer } from '@super-line/server'
|
|
11
|
+
import { createDurableNode } from '@super-line/transport-cloudflare'
|
|
12
|
+
import { cloudflareCollections } from '@super-line/collections-cloudflare'
|
|
13
|
+
import { contract } from './contract'
|
|
14
|
+
|
|
15
|
+
export class Node extends createDurableNode((ctx, env, base) =>
|
|
16
|
+
createSuperLineServer(contract, {
|
|
17
|
+
...base,
|
|
18
|
+
authenticate,
|
|
19
|
+
policies,
|
|
20
|
+
collections: cloudflareCollections(ctx.storage, contract.collections),
|
|
21
|
+
}),
|
|
22
|
+
) {}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`cloudflareCollections(storage, collections)` is synchronous and opens on the first operation, so building it in the object's constructor costs nothing and never throws. It needs the contract's post-merge `collections` map. `clustering: 'relay'`, `coordination: 'local'` — one node, so nothing is relayed.
|
|
26
|
+
|
|
27
|
+
## Limits
|
|
28
|
+
|
|
29
|
+
Durable Object SQLite is stricter than a SQLite file:
|
|
30
|
+
|
|
31
|
+
- **98 typed columns per collection** (the platform allows 100; two are row timestamps). A wider collection fails its own operations with an `INTERNAL` error naming it; the rest keep working.
|
|
32
|
+
- **2 MB per row.** A larger write fails, and the client receives `INTERNAL`.
|
|
33
|
+
- **100 bound values per query.** A snapshot filter needing more than 98 values scans the table and filters in JavaScript — same results, slower on a large table.
|
|
34
|
+
|
|
35
|
+
## Schema changes on a deployed object
|
|
36
|
+
|
|
37
|
+
A new optional field is altered in on the first operation. Any other change — a field that changed type, a removed field, a new required field — makes that collection fail its operations with an `INTERNAL` error naming it and the fix, while the rest of the object works. Either rename the collection (a new table; the old rows stay unread), or migrate `col_<name>` and its `col_meta` row with `ctx.storage.sql.exec` in your factory before you create the server.
|
|
38
|
+
|
|
39
|
+
The backend owns the `col_<name>` and `col_meta` tables in the object's storage.
|
|
40
|
+
|
|
41
|
+
## Serve another object's tables
|
|
42
|
+
|
|
43
|
+
`syncedSql` and `remoteRows` serve the tables of *other* Durable Objects — sources whose tables are created at runtime by arbitrary SQL — as one live, read-only collection on the node. Declare it on the contract with `remoteRowsCollection` from the `/contract` subpath, which imports only `zod` and `@super-line/core`:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import { remoteRowsCollection } from '@super-line/collections-cloudflare/contract'
|
|
47
|
+
|
|
48
|
+
export const contract = defineContract({ roles, plugins: [remoteRowsCollection('storeRows')] })
|
|
49
|
+
// rows: { key: `${storeId}:${table}:${id}`, storeId, table, id, data } — each row's columns opaque under `data`
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
In the source, `syncedSql(ctx, { onChange })` captures every row change with SQLite triggers, and pushes each committed batch to the node. Route every write through its `query`, and expose its reads for the node:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
import { DurableObject } from 'cloudflare:workers'
|
|
56
|
+
import { syncedSql, type QueryOptions, type Statement } from '@super-line/collections-cloudflare'
|
|
57
|
+
|
|
58
|
+
export class Store extends DurableObject<Env> {
|
|
59
|
+
private sync = syncedSql(this.ctx, {
|
|
60
|
+
onChange: (push) => {
|
|
61
|
+
const [node, storeId] = this.ctx.id.name!.split(':') as [string, string]
|
|
62
|
+
return this.env.NODE.getByName(node).storeChanged(storeId, push)
|
|
63
|
+
},
|
|
64
|
+
})
|
|
65
|
+
query(statements: Statement[], opts?: QueryOptions) { return this.sync.query(statements, opts) }
|
|
66
|
+
snapshot(table: string, limit: number) { return this.sync.snapshot(table, limit) }
|
|
67
|
+
get(table: string, id: string) { return this.sync.get(table, id) }
|
|
68
|
+
changesSince(clock: number) { return this.sync.changesSince(clock) }
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
On the node, `remoteRows` is a `self` backend fed by those pushes, served beside this package's own backend through `composeCollections` from `@super-line/server`:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
export class Node extends createDurableNode((ctx, env, base) => {
|
|
76
|
+
const storeRows = remoteRows({
|
|
77
|
+
collection: 'storeRows',
|
|
78
|
+
storage: ctx.storage,
|
|
79
|
+
stub: (storeId) => env.STORE.getByName(`${ctx.id.name}:${storeId}`),
|
|
80
|
+
onWipe: (storeId) => closeReadersOf(storeId), // conn.raw.close(4409, 'resync') → they resubscribe into a fresh snapshot
|
|
81
|
+
})
|
|
82
|
+
const { storeRows: _, ...owned } = contract.collections
|
|
83
|
+
const server = createSuperLineServer(contract, {
|
|
84
|
+
...base,
|
|
85
|
+
authenticate,
|
|
86
|
+
policies,
|
|
87
|
+
collections: composeCollections({ default: cloudflareCollections(ctx.storage, owned), storeRows }),
|
|
88
|
+
})
|
|
89
|
+
void server.ready.then(() => storeRows.resume()) // a wake restores subscriptions without reading the source: catch every source up
|
|
90
|
+
return Object.assign(server, { storeRows })
|
|
91
|
+
}) {
|
|
92
|
+
storeChanged(storeId: string, push: RemotePush) {
|
|
93
|
+
return this.server.storeRows.ingest(storeId, push)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
A subscription must pin both `storeId` and `table` with `eq()`, and writes fail with `FORBIDDEN` — they go through the source's `query`. Dropping, renaming or altering a table, pruned deletes, a backlog over `maxPullChanges` or a reset source breaks that source's history: `onWipe` runs, and its subscribers resync. Store ids and table names must be colon-free. The source owns every `_sl_`-prefixed object in its SQLite.
|
|
99
|
+
|
|
100
|
+
- 📖 Guide: [Host a node on Cloudflare Durable Objects](https://super-line.dogar.biz/how-to/cloudflare-durable-objects)
|
|
101
|
+
- 📖 Guide: [Serve another Durable Object's tables](https://super-line.dogar.biz/how-to/cloudflare-remote-rows)
|
|
102
|
+
- 📚 Reference: [Backends & clustering](https://super-line.dogar.biz/collections/backends)
|
|
103
|
+
- 🧩 Source: <https://github.com/mertdogar/super-line>
|
|
104
|
+
|
|
105
|
+
MIT © Mert
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import * as _super_line_core from '@super-line/core';
|
|
2
|
+
import * as z from 'zod';
|
|
3
|
+
|
|
4
|
+
/** A cell on the wire: SQLite's own types, with a blob as base64 under `blob`. */
|
|
5
|
+
type Value = string | number | null | {
|
|
6
|
+
blob: string;
|
|
7
|
+
};
|
|
8
|
+
type Statement = {
|
|
9
|
+
sql: string;
|
|
10
|
+
params?: Value[];
|
|
11
|
+
};
|
|
12
|
+
type Result = {
|
|
13
|
+
columns: string[];
|
|
14
|
+
rows: Value[][];
|
|
15
|
+
meta: {
|
|
16
|
+
rowsRead: number;
|
|
17
|
+
rowsWritten: number;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
/** `bytes` is set when the batch's rows passed `maxBytes`; the message then names the cap. */
|
|
21
|
+
type Outcome = {
|
|
22
|
+
results: Result[];
|
|
23
|
+
} | {
|
|
24
|
+
error: string;
|
|
25
|
+
statement: number;
|
|
26
|
+
bytes?: number;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* `readOnly` rolls the batch back as soon as a statement writes a row or changes the schema; `maxBytes` rolls it
|
|
30
|
+
* back as soon as the serialized rows of the batch pass the cap.
|
|
31
|
+
*/
|
|
32
|
+
type QueryOptions = {
|
|
33
|
+
readOnly?: boolean;
|
|
34
|
+
maxBytes?: number;
|
|
35
|
+
};
|
|
36
|
+
/** One row of a source's table, as the node serves it. `key` is `${storeId}:${table}:${id}`. */
|
|
37
|
+
declare const remoteRowSchema: z.ZodObject<{
|
|
38
|
+
key: z.ZodString;
|
|
39
|
+
storeId: z.ZodString;
|
|
40
|
+
table: z.ZodString;
|
|
41
|
+
id: z.ZodString;
|
|
42
|
+
data: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodNull, z.ZodObject<{
|
|
43
|
+
blob: z.ZodString;
|
|
44
|
+
}, z.core.$strip>]>>;
|
|
45
|
+
}, z.core.$strip>;
|
|
46
|
+
type RemoteRowsRow = z.infer<typeof remoteRowSchema>;
|
|
47
|
+
type RemoteRowsFragment<N extends string> = {
|
|
48
|
+
collections: {
|
|
49
|
+
[K in N]: {
|
|
50
|
+
schema: typeof remoteRowSchema;
|
|
51
|
+
key: 'key';
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* The contract half of `remoteRows`: one read-only collection `name` holding the rows of every source the node
|
|
57
|
+
* fronts, keyed `${storeId}:${table}:${id}`, with each row's columns opaque under `data`. Its own subpath, so a
|
|
58
|
+
* contract package doesn't pull in the Durable Object side.
|
|
59
|
+
*
|
|
60
|
+
* ```ts
|
|
61
|
+
* defineContract({ roles, plugins: [remoteRowsCollection('storeRows')] })
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
declare function remoteRowsCollection<const N extends string>(name: N): _super_line_core.ContractPlugin<RemoteRowsFragment<N>>;
|
|
65
|
+
|
|
66
|
+
export { type Outcome, type QueryOptions, type RemoteRowsRow, type Result, type Statement, type Value, remoteRowSchema, remoteRowsCollection };
|
package/dist/contract.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// src/contract.ts
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import { defineContractPlugin } from "@super-line/core";
|
|
4
|
+
var value = z.union([z.string(), z.number(), z.null(), z.object({ blob: z.string() })]);
|
|
5
|
+
var remoteRowSchema = z.object({
|
|
6
|
+
key: z.string(),
|
|
7
|
+
storeId: z.string(),
|
|
8
|
+
table: z.string(),
|
|
9
|
+
id: z.string(),
|
|
10
|
+
data: z.record(z.string(), value)
|
|
11
|
+
});
|
|
12
|
+
function remoteRowsCollection(name) {
|
|
13
|
+
const fragment = { collections: { [name]: { schema: remoteRowSchema, key: "key" } } };
|
|
14
|
+
return defineContractPlugin(`remote-rows:${name}`, fragment);
|
|
15
|
+
}
|
|
16
|
+
export {
|
|
17
|
+
remoteRowSchema,
|
|
18
|
+
remoteRowsCollection
|
|
19
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { SelfCollectionStore, CollectionDef, RelayCollectionStore } from '@super-line/core';
|
|
2
|
+
import { Value, Statement, QueryOptions, Outcome } from './contract.js';
|
|
3
|
+
export { Result } from './contract.js';
|
|
4
|
+
import 'zod';
|
|
5
|
+
|
|
6
|
+
/** One row of a user table: `id` is its single-column primary key, else its rowid, as a string. */
|
|
7
|
+
type RemoteRow = {
|
|
8
|
+
table: string;
|
|
9
|
+
id: string;
|
|
10
|
+
data: Record<string, Value>;
|
|
11
|
+
};
|
|
12
|
+
type RemoteChange = {
|
|
13
|
+
table: string;
|
|
14
|
+
id: string;
|
|
15
|
+
op: 'put';
|
|
16
|
+
data: Record<string, Value>;
|
|
17
|
+
} | {
|
|
18
|
+
table: string;
|
|
19
|
+
id: string;
|
|
20
|
+
op: 'delete';
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* `wipeAll`: the history since that clock is gone (pruned, a dropped, renamed or altered table, a clock from the
|
|
24
|
+
* future) or is longer than `maxPullChanges`; `changes` is then empty.
|
|
25
|
+
*/
|
|
26
|
+
type RemoteChanges = {
|
|
27
|
+
epoch: string;
|
|
28
|
+
clock: number;
|
|
29
|
+
changes: RemoteChange[];
|
|
30
|
+
wipeAll: boolean;
|
|
31
|
+
};
|
|
32
|
+
/** `changes` is `undefined` when the batch was too large to inline or broke the history: pull `changesSince`. */
|
|
33
|
+
type RemotePush = {
|
|
34
|
+
epoch: string;
|
|
35
|
+
clock: number;
|
|
36
|
+
changes: RemoteChange[] | undefined;
|
|
37
|
+
};
|
|
38
|
+
interface SyncedSqlOptions {
|
|
39
|
+
/** Delete markers kept for `changesSince`; older ones are pruned and the history starts later. Default 1000. */
|
|
40
|
+
maxTombstones?: number;
|
|
41
|
+
/** Changes a push carries inline; a larger batch pushes the clock only. Default 500. */
|
|
42
|
+
maxInlineChanges?: number;
|
|
43
|
+
/** Changes `changesSince` answers with; a longer backlog answers `wipeAll` instead. Default 5000. */
|
|
44
|
+
maxPullChanges?: number;
|
|
45
|
+
/** Called after every batch that changed rows, once it committed. A throw or rejection is logged and dropped. */
|
|
46
|
+
onChange?: (push: RemotePush) => unknown;
|
|
47
|
+
}
|
|
48
|
+
interface SyncedSql {
|
|
49
|
+
query(statements: Statement[], opts?: QueryOptions): Outcome;
|
|
50
|
+
snapshot(table: string, limit: number): RemoteRow[];
|
|
51
|
+
get(table: string, id: string): RemoteRow | undefined;
|
|
52
|
+
changesSince(clock: number): RemoteChanges;
|
|
53
|
+
}
|
|
54
|
+
declare const READ_ONLY_MESSAGE = "Read-only: the statement wrote rows or changed the schema and was rolled back";
|
|
55
|
+
/**
|
|
56
|
+
* Change capture over a Durable Object's own SQLite, for an object whose tables are created at runtime by arbitrary
|
|
57
|
+
* SQL. `query` is the object's one write funnel: a batch runs in order inside one `transactionSync`, and the first
|
|
58
|
+
* failure rolls the whole batch back and reports SQLite's message.
|
|
59
|
+
*
|
|
60
|
+
* Every user table gets `AFTER INSERT/UPDATE/DELETE` triggers that log into `_sl_changes` at a clock that bumps once
|
|
61
|
+
* per write batch; a batch that changes the schema re-syncs them, and a table it created is logged whole. A row's id
|
|
62
|
+
* is its single-column primary key, else its rowid; a `WITHOUT ROWID` table with a composite key is skipped, with a
|
|
63
|
+
* warning. Dropping, renaming or altering the columns of a table breaks the history, so `changesSince` from before
|
|
64
|
+
* it says `wipeAll`, as it does past `maxTombstones` pruned deletes and for a backlog over `maxPullChanges`. The `_sl_` prefix is reserved, and the connection runs with
|
|
65
|
+
* `recursive_triggers` on, so a `REPLACE` that evicts a conflicting row logs its delete.
|
|
66
|
+
*
|
|
67
|
+
* ```ts
|
|
68
|
+
* private sync = syncedSql(this.ctx, { onChange: (push) => this.env.NODE.getByName('node').changed(push) })
|
|
69
|
+
* query(statements: Statement[], opts?: QueryOptions) { return this.sync.query(statements, opts) }
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
declare function syncedSql(ctx: DurableObjectState, opts?: SyncedSqlOptions): SyncedSql;
|
|
73
|
+
|
|
74
|
+
type Awaitable<T> = T | Promise<T>;
|
|
75
|
+
/** A source as the node reaches it: `syncedSql`'s reads, over RPC. A Durable Object stub satisfies it. */
|
|
76
|
+
interface RemoteSource {
|
|
77
|
+
snapshot(table: string, limit: number): Awaitable<RemoteRow[]>;
|
|
78
|
+
get(table: string, id: string): Awaitable<RemoteRow | undefined>;
|
|
79
|
+
changesSince(clock: number): Awaitable<RemoteChanges>;
|
|
80
|
+
}
|
|
81
|
+
/** Where the node keeps the last clock it applied per source. `ctx.storage` satisfies it. */
|
|
82
|
+
interface RemoteRowsStorage {
|
|
83
|
+
get<T>(key: string): Promise<T | undefined>;
|
|
84
|
+
put<T>(key: string, value: T): Promise<void>;
|
|
85
|
+
list<T>(options: {
|
|
86
|
+
prefix: string;
|
|
87
|
+
}): Promise<Map<string, T>>;
|
|
88
|
+
}
|
|
89
|
+
interface RemoteRowsOptions {
|
|
90
|
+
/** The contract collection, declared with `remoteRowsCollection(collection)`. */
|
|
91
|
+
collection: string;
|
|
92
|
+
storage: RemoteRowsStorage;
|
|
93
|
+
stub(storeId: string): RemoteSource;
|
|
94
|
+
/**
|
|
95
|
+
* The source's history broke (a dropped, renamed or altered table, pruned deletes, a reset source), so rows its
|
|
96
|
+
* subscribers hold may be stale. Close their connections (`conn.raw.close(4409, 'resync')`): the client reconnects
|
|
97
|
+
* and resubscribes into a fresh snapshot. Default: nothing.
|
|
98
|
+
*/
|
|
99
|
+
onWipe?(storeId: string): unknown;
|
|
100
|
+
/** Rows a snapshot reads from the source table, at most. Default 1000. */
|
|
101
|
+
snapshotLimit?: number;
|
|
102
|
+
}
|
|
103
|
+
interface RemoteRowsStore extends SelfCollectionStore {
|
|
104
|
+
/** The forwarder's target: a source's `onChange` push. Applies it inline when it is the next clock, else pulls. */
|
|
105
|
+
ingest(storeId: string, push: RemotePush): Promise<void>;
|
|
106
|
+
/**
|
|
107
|
+
* Pull what these sources changed since their last clock — on wake, for the sources with surviving subscribers.
|
|
108
|
+
* With no ids, every source the node keeps a clock for.
|
|
109
|
+
*/
|
|
110
|
+
resume(storeIds?: string[]): Promise<void>;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The node side of a foreign Durable Object's SQLite: a read-only `self` {@link SelfCollectionStore} serving the rows
|
|
114
|
+
* of every source the node fronts as one collection. The source pushes each committed batch to {@link
|
|
115
|
+
* RemoteRowsStore.ingest} (`syncedSql`'s `onChange`); the node keeps the last clock per source in `storage` and pulls
|
|
116
|
+
* `changesSince` when a push skips a clock, carries no changes or comes from a new epoch. A store's feed starts at
|
|
117
|
+
* its first subscribe, and every subscribe pulls first.
|
|
118
|
+
*
|
|
119
|
+
* A snapshot must pin `storeId` and `table` by equality (`VALIDATION` otherwise); the rest of the query runs on the
|
|
120
|
+
* rows read. Changes carry no prior row, so the server sends a data-less delete to a subscriber the new row doesn't
|
|
121
|
+
* match — unless its filter rules out the row's `storeId` or `table`, which never change for a key. Writes throw
|
|
122
|
+
* `FORBIDDEN`: they go through the source's own path.
|
|
123
|
+
*
|
|
124
|
+
* ```ts
|
|
125
|
+
* const rows = remoteRows({ collection: 'storeRows', storage: ctx.storage, stub: (id) => env.STORE.getByName(id) })
|
|
126
|
+
* createSuperLineServer(contract, { ...base, collections: composeCollections({ default, storeRows: rows }) })
|
|
127
|
+
* ```
|
|
128
|
+
*/
|
|
129
|
+
declare function remoteRows(opts: RemoteRowsOptions): RemoteRowsStore;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The Durable Object SQLite RelayCollectionStore: every LWW collection on the contract gets its own typed table
|
|
133
|
+
* (`col_<name>`) in the object's own storage, exactly as `sqliteCollections` lays it out, and one
|
|
134
|
+
* `transactionSync` spans all of them, so a cross-collection batch stays atomic.
|
|
135
|
+
*
|
|
136
|
+
* Opens lazily, on the first operation, so building it in the object's constructor never throws (a throw there
|
|
137
|
+
* resets the object). A collection that can't open — its schema drifted non-additively, or it plans more than 98
|
|
138
|
+
* columns — fails only its own operations, with an `INTERNAL` error that names it and the fix; the rest keep
|
|
139
|
+
* working. Additive drift (new optional fields) is altered in, as with `sqliteCollections`.
|
|
140
|
+
*
|
|
141
|
+
* ```ts
|
|
142
|
+
* createSuperLineServer(contract, { ...base, collections: cloudflareCollections(ctx.storage, contract.collections) })
|
|
143
|
+
* ```
|
|
144
|
+
*/
|
|
145
|
+
declare function cloudflareCollections(storage: DurableObjectStorage, collections: Record<string, CollectionDef>): RelayCollectionStore;
|
|
146
|
+
|
|
147
|
+
export { Outcome, QueryOptions, READ_ONLY_MESSAGE, type RemoteChange, type RemoteChanges, type RemotePush, type RemoteRow, type RemoteRowsOptions, type RemoteRowsStorage, type RemoteRowsStore, type RemoteSource, Statement, type SyncedSql, type SyncedSqlOptions, Value, cloudflareCollections, remoteRows, syncedSql };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { SuperLineError as SuperLineError2, applyQuery as applyQuery2, isCrdtCollection, matchesFilter } from "@super-line/core";
|
|
3
|
+
|
|
4
|
+
// ../collections-sqlite/src/sql.ts
|
|
5
|
+
import { DEGENERATE_DATA_COLUMN, planColumns } from "@super-line/core";
|
|
6
|
+
var IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
7
|
+
var sqlType = (kind) => kind === "text" ? "TEXT" : kind === "real" ? "REAL" : kind === "integer-bool" ? "INTEGER" : "TEXT";
|
|
8
|
+
var norm = (v) => typeof v === "boolean" ? v ? 1 : 0 : v;
|
|
9
|
+
var kindMatches = (kind, v) => kind === "text" && typeof v === "string" || kind === "real" && typeof v === "number" || kind === "integer-bool" && typeof v === "boolean";
|
|
10
|
+
var META_SQL = {
|
|
11
|
+
create: `CREATE TABLE IF NOT EXISTS col_meta (collection TEXT PRIMARY KEY, fingerprint TEXT NOT NULL)`,
|
|
12
|
+
get: `SELECT fingerprint FROM col_meta WHERE collection = ?`,
|
|
13
|
+
insert: `INSERT INTO col_meta (collection, fingerprint) VALUES (?, ?)`,
|
|
14
|
+
update: `UPDATE col_meta SET fingerprint = ? WHERE collection = ?`
|
|
15
|
+
};
|
|
16
|
+
function defineTable(n, def) {
|
|
17
|
+
if (!IDENT.test(n) || n.length > 60) throw new Error(`invalid collection name '${n}'`);
|
|
18
|
+
const plan = planColumns(def);
|
|
19
|
+
const name = `col_${n}`;
|
|
20
|
+
const indexes = (def.indexes ?? []).map((fields, index) => {
|
|
21
|
+
if (fields.length === 0 || fields.some((field) => !plan.columns.some((column) => column.name === field)))
|
|
22
|
+
throw new Error(`invalid index ${index} on '${n}'`);
|
|
23
|
+
return `CREATE INDEX IF NOT EXISTS "${name}_idx_${index}" ON "${name}" (${fields.map((field) => `"${field}"`).join(", ")})`;
|
|
24
|
+
});
|
|
25
|
+
const colList = plan.columns.map((c) => `"${c.name}"`).join(", ");
|
|
26
|
+
const setList = [...plan.columns.filter((c) => c.name !== plan.key).map((c) => `"${c.name}" = ?`), `"_sl_updated_at" = ?`].join(", ");
|
|
27
|
+
return {
|
|
28
|
+
name,
|
|
29
|
+
plan,
|
|
30
|
+
byName: new Map(plan.columns.map((c) => [c.name, c])),
|
|
31
|
+
create: createTableSql(name, plan),
|
|
32
|
+
indexes,
|
|
33
|
+
sql: {
|
|
34
|
+
get: `SELECT ${colList} FROM "${name}" WHERE "${plan.key}" = ?`,
|
|
35
|
+
has: `SELECT 1 FROM "${name}" WHERE "${plan.key}" = ?`,
|
|
36
|
+
insert: `INSERT INTO "${name}" (${colList}, "_sl_created_at", "_sl_updated_at") VALUES (${plan.columns.map(() => "?").join(", ")}, ?, ?)`,
|
|
37
|
+
update: `UPDATE "${name}" SET ${setList} WHERE "${plan.key}" = ?`,
|
|
38
|
+
delete: `DELETE FROM "${name}" WHERE "${plan.key}" = ?`
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function resolveField(t, field) {
|
|
43
|
+
if (t.plan.degenerate) return { at: "json", col: DEGENERATE_DATA_COLUMN, path: "$." + field };
|
|
44
|
+
const dot = field.indexOf(".");
|
|
45
|
+
const head = dot === -1 ? field : field.slice(0, dot);
|
|
46
|
+
const col = t.byName.get(head);
|
|
47
|
+
if (!col) return { at: "absent" };
|
|
48
|
+
if (col.kind === "json") return { at: "json", col: col.name, path: dot === -1 ? "$" : "$." + field.slice(dot + 1) };
|
|
49
|
+
return dot === -1 ? { at: "scalar", col } : { at: "absent" };
|
|
50
|
+
}
|
|
51
|
+
var TRUE = { sql: "1=1", params: [], exact: true };
|
|
52
|
+
var FALSE = { sql: "1=0", params: [], exact: true };
|
|
53
|
+
function compileExpr(t, expr) {
|
|
54
|
+
switch (expr.op) {
|
|
55
|
+
case "and":
|
|
56
|
+
case "or": {
|
|
57
|
+
if (expr.args.length === 0) return expr.op === "and" ? TRUE : FALSE;
|
|
58
|
+
const parts = [];
|
|
59
|
+
const params = [];
|
|
60
|
+
let exact = true;
|
|
61
|
+
for (const arg of expr.args) {
|
|
62
|
+
const c = compileExpr(t, arg);
|
|
63
|
+
if (!c) {
|
|
64
|
+
if (expr.op === "or") return null;
|
|
65
|
+
exact = false;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
exact &&= c.exact;
|
|
69
|
+
parts.push(`(${c.sql})`);
|
|
70
|
+
params.push(...c.params);
|
|
71
|
+
}
|
|
72
|
+
if (parts.length === 0) return null;
|
|
73
|
+
return { sql: parts.join(expr.op === "and" ? " AND " : " OR "), params, exact };
|
|
74
|
+
}
|
|
75
|
+
case "not": {
|
|
76
|
+
const c = compileExpr(t, expr.arg);
|
|
77
|
+
return c?.exact ? { sql: `NOT (${c.sql})`, params: c.params, exact: true } : null;
|
|
78
|
+
}
|
|
79
|
+
case "eq": {
|
|
80
|
+
const ref = resolveField(t, expr.field);
|
|
81
|
+
if (ref.at === "absent") return FALSE;
|
|
82
|
+
if (ref.at === "scalar") {
|
|
83
|
+
if (expr.value === null) return ref.col.nullable ? { sql: `"${ref.col.name}" IS NULL`, params: [], exact: true } : FALSE;
|
|
84
|
+
if (!kindMatches(ref.col.kind, expr.value)) return FALSE;
|
|
85
|
+
return { sql: `"${ref.col.name}" IS ?`, params: [norm(expr.value)], exact: true };
|
|
86
|
+
}
|
|
87
|
+
const je = `json_extract("${ref.col}", ?)`;
|
|
88
|
+
return expr.value === null ? { sql: `${je} IS NULL`, params: [ref.path], exact: false } : { sql: `${je} IS ?`, params: [ref.path, norm(expr.value)], exact: false };
|
|
89
|
+
}
|
|
90
|
+
case "neq": {
|
|
91
|
+
const ref = resolveField(t, expr.field);
|
|
92
|
+
if (ref.at === "absent") return TRUE;
|
|
93
|
+
if (ref.at === "scalar") {
|
|
94
|
+
if (expr.value === null) return ref.col.nullable ? { sql: `"${ref.col.name}" IS NOT NULL`, params: [], exact: true } : TRUE;
|
|
95
|
+
if (!kindMatches(ref.col.kind, expr.value)) return TRUE;
|
|
96
|
+
return { sql: `"${ref.col.name}" IS NOT ?`, params: [norm(expr.value)], exact: true };
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
case "lt":
|
|
101
|
+
case "lte":
|
|
102
|
+
case "gt":
|
|
103
|
+
case "gte": {
|
|
104
|
+
if (expr.value === null) return FALSE;
|
|
105
|
+
const op = expr.op === "lt" ? "<" : expr.op === "lte" ? "<=" : expr.op === "gt" ? ">" : ">=";
|
|
106
|
+
const ref = resolveField(t, expr.field);
|
|
107
|
+
if (ref.at === "absent") return FALSE;
|
|
108
|
+
if (ref.at === "scalar") {
|
|
109
|
+
if (ref.col.kind === "real" && typeof expr.value === "number") {
|
|
110
|
+
return { sql: `COALESCE("${ref.col.name}" ${op} ?, 0)`, params: [expr.value], exact: true };
|
|
111
|
+
}
|
|
112
|
+
if (ref.col.kind === "text" && typeof expr.value === "string") return null;
|
|
113
|
+
return FALSE;
|
|
114
|
+
}
|
|
115
|
+
if (typeof expr.value !== "number") return null;
|
|
116
|
+
return { sql: `COALESCE(json_extract("${ref.col}", ?) ${op} ?, 0)`, params: [ref.path, expr.value], exact: false };
|
|
117
|
+
}
|
|
118
|
+
case "in": {
|
|
119
|
+
if (expr.values.length === 0) return FALSE;
|
|
120
|
+
const ref = resolveField(t, expr.field);
|
|
121
|
+
if (ref.at === "absent") return FALSE;
|
|
122
|
+
if (ref.at === "scalar") {
|
|
123
|
+
const usable = expr.values.filter((v) => kindMatches(ref.col.kind, v));
|
|
124
|
+
const parts = [];
|
|
125
|
+
const params = [];
|
|
126
|
+
if (usable.length > 0) {
|
|
127
|
+
parts.push(`COALESCE("${ref.col.name}" IN (${usable.map(() => "?").join(",")}), 0)`);
|
|
128
|
+
params.push(...usable.map(norm));
|
|
129
|
+
}
|
|
130
|
+
if (expr.values.includes(null) && ref.col.nullable) parts.push(`"${ref.col.name}" IS NULL`);
|
|
131
|
+
return parts.length === 0 ? FALSE : { sql: parts.join(" OR "), params, exact: true };
|
|
132
|
+
}
|
|
133
|
+
if (expr.values.some((v) => v === null)) return null;
|
|
134
|
+
return {
|
|
135
|
+
sql: `COALESCE(json_extract("${ref.col}", ?) IN (${expr.values.map(() => "?").join(",")}), 0)`,
|
|
136
|
+
params: [ref.path, ...expr.values.map(norm)],
|
|
137
|
+
exact: false
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
default:
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function compileOrder(t, orderBy) {
|
|
145
|
+
const parts = [];
|
|
146
|
+
for (const { field, dir } of orderBy) {
|
|
147
|
+
const ref = resolveField(t, field);
|
|
148
|
+
if (ref.at !== "scalar" || ref.col.kind !== "real") return null;
|
|
149
|
+
parts.push(dir === "desc" ? `"${ref.col.name}" DESC NULLS FIRST` : `"${ref.col.name}" ASC NULLS LAST`);
|
|
150
|
+
}
|
|
151
|
+
return parts.join(", ");
|
|
152
|
+
}
|
|
153
|
+
function snapshotSql(t, query, maxParams = Infinity) {
|
|
154
|
+
let sql = `SELECT ${t.plan.columns.map((c) => `"${c.name}"`).join(", ")} FROM "${t.name}"`;
|
|
155
|
+
const params = [];
|
|
156
|
+
let where = query.filter ? compileExpr(t, query.filter) : TRUE;
|
|
157
|
+
if (where && where.params.length + 2 > maxParams) where = null;
|
|
158
|
+
if (query.filter && where) {
|
|
159
|
+
sql += ` WHERE ${where.sql}`;
|
|
160
|
+
params.push(...where.params);
|
|
161
|
+
}
|
|
162
|
+
const order = query.orderBy?.length ? compileOrder(t, query.orderBy) : "";
|
|
163
|
+
if (!where?.exact || order === null) return { sql, params, exact: false };
|
|
164
|
+
if (order) sql += ` ORDER BY ${order}`;
|
|
165
|
+
if (query.limit !== void 0 || query.offset !== void 0) {
|
|
166
|
+
sql += ` LIMIT ? OFFSET ?`;
|
|
167
|
+
params.push(query.limit ?? -1, query.offset ?? 0);
|
|
168
|
+
}
|
|
169
|
+
return { sql, params, exact: true };
|
|
170
|
+
}
|
|
171
|
+
var rowMetaSql = (t, count) => `SELECT "${t.plan.key}" AS id, "_sl_created_at" AS c, "_sl_updated_at" AS u FROM "${t.name}" WHERE "${t.plan.key}" IN (${Array.from({ length: count }, () => "?").join(",")})`;
|
|
172
|
+
var encode = (col, v) => {
|
|
173
|
+
if (col.kind === "json") return v === void 0 ? null : JSON.stringify(v);
|
|
174
|
+
if (v === void 0 || v === null) return null;
|
|
175
|
+
return norm(v);
|
|
176
|
+
};
|
|
177
|
+
function rowParams(t, id, row) {
|
|
178
|
+
if (t.plan.degenerate) return [id, JSON.stringify(row)];
|
|
179
|
+
const rec = row;
|
|
180
|
+
return t.plan.columns.map((c) => c.name === t.plan.key ? id : encode(c, rec[c.name]));
|
|
181
|
+
}
|
|
182
|
+
function updateParams(t, id, row, ts) {
|
|
183
|
+
return [...rowParams(t, id, row).filter((_, i) => t.plan.columns[i].name !== t.plan.key), ts, id];
|
|
184
|
+
}
|
|
185
|
+
function fromRow(t, dbRow) {
|
|
186
|
+
if (t.plan.degenerate) return JSON.parse(dbRow[DEGENERATE_DATA_COLUMN]);
|
|
187
|
+
const row = {};
|
|
188
|
+
for (const col of t.plan.columns) {
|
|
189
|
+
const v = dbRow[col.name];
|
|
190
|
+
if (col.kind === "json") {
|
|
191
|
+
if (v !== null) row[col.name] = JSON.parse(v);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (v === null) {
|
|
195
|
+
if (col.nullable) row[col.name] = null;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
row[col.name] = col.kind === "integer-bool" ? v === 1 : v;
|
|
199
|
+
}
|
|
200
|
+
return row;
|
|
201
|
+
}
|
|
202
|
+
function createTableSql(name, plan) {
|
|
203
|
+
const cols = plan.columns.map((c) => {
|
|
204
|
+
const constraint = c.name === plan.key ? " NOT NULL PRIMARY KEY" : c.optional || c.nullable ? "" : " NOT NULL";
|
|
205
|
+
return `"${c.name}" ${sqlType(c.kind)}${constraint}`;
|
|
206
|
+
});
|
|
207
|
+
return `CREATE TABLE IF NOT EXISTS "${name}" (${cols.join(", ")}, "_sl_created_at" INTEGER NOT NULL, "_sl_updated_at" INTEGER NOT NULL)`;
|
|
208
|
+
}
|
|
209
|
+
var parseFingerprint = (fp) => {
|
|
210
|
+
const parts = fp.split(";");
|
|
211
|
+
const cols = /* @__PURE__ */ new Map();
|
|
212
|
+
for (const p of parts.slice(2)) cols.set(p.slice(0, p.indexOf(":")), p);
|
|
213
|
+
return { head: parts[0], key: parts[1], cols };
|
|
214
|
+
};
|
|
215
|
+
function driftOf(t, stored) {
|
|
216
|
+
const old = parseFingerprint(stored);
|
|
217
|
+
const next = parseFingerprint(t.plan.fingerprint);
|
|
218
|
+
if (old.head !== next.head || old.key !== next.key) return { refuse: "changed its key or shape non-additively" };
|
|
219
|
+
for (const [name, entry] of old.cols) {
|
|
220
|
+
if (next.cols.get(name) !== entry) return { refuse: `changed or removed field '${name}'` };
|
|
221
|
+
}
|
|
222
|
+
const alter = [];
|
|
223
|
+
for (const col of t.plan.columns) {
|
|
224
|
+
if (old.cols.has(col.name)) continue;
|
|
225
|
+
if (!col.optional && !col.nullable) return { refuse: `added required field '${col.name}' (existing rows can't be backfilled)` };
|
|
226
|
+
alter.push(`ALTER TABLE "${t.name}" ADD COLUMN "${col.name}" ${sqlType(col.kind)}`);
|
|
227
|
+
}
|
|
228
|
+
return { alter };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// src/synced-sql.ts
|
|
232
|
+
import { getLogger } from "@logtape/logtape";
|
|
233
|
+
var log = getLogger(["super-line", "collections-cloudflare", "synced-sql"]);
|
|
234
|
+
var READ_ONLY_MESSAGE = "Read-only: the statement wrote rows or changed the schema and was rolled back";
|
|
235
|
+
var MAX_BYTES = 16 * 1024 * 1024;
|
|
236
|
+
var INSTALL = [
|
|
237
|
+
"CREATE TABLE IF NOT EXISTS _sl_meta (epoch TEXT NOT NULL, clock INTEGER NOT NULL, historyStartsAt INTEGER NOT NULL)",
|
|
238
|
+
"CREATE TABLE IF NOT EXISTS _sl_changes (tbl TEXT NOT NULL, id, clock INTEGER NOT NULL, op TEXT NOT NULL, PRIMARY KEY (tbl, id))",
|
|
239
|
+
"CREATE INDEX IF NOT EXISTS _sl_changes_clock ON _sl_changes (op, clock)"
|
|
240
|
+
];
|
|
241
|
+
var SIGNATURE_SQL = `SELECT group_concat(type || ' ' || name || ' ' || ifnull(sql, ''), char(10)) AS s FROM sqlite_master
|
|
242
|
+
WHERE tbl_name NOT GLOB '_sl_*'`;
|
|
243
|
+
var TRIGGERS_SQL = `SELECT name, tbl_name AS tbl FROM sqlite_master WHERE type = 'trigger' AND name GLOB '_sl_*'`;
|
|
244
|
+
var TRACKED_SQL = `SELECT r.name AS name, r.tbl_name AS tbl, t.sql AS sql FROM sqlite_master AS r
|
|
245
|
+
JOIN sqlite_master AS t ON t.type = 'table' AND t.name = r.tbl_name WHERE r.type = 'trigger' AND r.name GLOB '_sl_i_*'`;
|
|
246
|
+
var TABLES_SQL = `SELECT l.name AS name, l.wr AS wr,
|
|
247
|
+
(SELECT count(*) FROM pragma_table_info(l.name) WHERE pk > 0) AS pks,
|
|
248
|
+
(SELECT name FROM pragma_table_info(l.name) WHERE pk = 1) AS pk
|
|
249
|
+
FROM pragma_table_list() AS l
|
|
250
|
+
WHERE l.schema = 'main' AND l.type = 'table' AND l.name NOT GLOB 'sqlite_*' AND l.name NOT GLOB '_cf_*' AND l.name NOT GLOB '_sl_*'
|
|
251
|
+
AND l.name NOT GLOB '__miniflare_*'`;
|
|
252
|
+
var OPS = ["i", "u", "d"];
|
|
253
|
+
var triggerName = (op, table) => `_sl_${op}_${table}`;
|
|
254
|
+
var ident = (name) => `"${name.replaceAll('"', '""')}"`;
|
|
255
|
+
var literal = (text) => `'${text.replaceAll("'", "''")}'`;
|
|
256
|
+
var toBase64 = (buffer) => {
|
|
257
|
+
let s = "";
|
|
258
|
+
for (const byte of new Uint8Array(buffer)) s += String.fromCharCode(byte);
|
|
259
|
+
return btoa(s);
|
|
260
|
+
};
|
|
261
|
+
var fromBase64 = (text) => Uint8Array.from(atob(text), (c) => c.charCodeAt(0)).buffer;
|
|
262
|
+
var decode = (value) => value !== null && typeof value === "object" ? fromBase64(value.blob) : value;
|
|
263
|
+
var encode2 = (value) => value instanceof ArrayBuffer ? { blob: toBase64(value) } : value;
|
|
264
|
+
var idOf = (value) => value instanceof ArrayBuffer ? toBase64(value) : String(value);
|
|
265
|
+
var utf8 = new TextEncoder();
|
|
266
|
+
var ResultTooLarge = class extends Error {
|
|
267
|
+
constructor(bytes) {
|
|
268
|
+
super("The result is over the cap");
|
|
269
|
+
this.bytes = bytes;
|
|
270
|
+
}
|
|
271
|
+
bytes;
|
|
272
|
+
};
|
|
273
|
+
var record = (tbl, id, op, when = "") => `DELETE FROM _sl_changes WHERE tbl = ${tbl} AND id = ${id}; INSERT INTO _sl_changes (tbl, id, clock, op) SELECT ${tbl}, ${id}, clock + 1, '${op}' FROM _sl_meta${when};`;
|
|
274
|
+
var triggers = (table, key) => {
|
|
275
|
+
const tbl = literal(table);
|
|
276
|
+
const on = ident(table);
|
|
277
|
+
const [i, u, d] = OPS.map((op) => ident(triggerName(op, table)));
|
|
278
|
+
return [
|
|
279
|
+
`CREATE TRIGGER IF NOT EXISTS ${i} AFTER INSERT ON ${on} BEGIN ${record(tbl, `NEW.${key}`, "put")} END`,
|
|
280
|
+
`CREATE TRIGGER IF NOT EXISTS ${u} AFTER UPDATE ON ${on} BEGIN ${record(tbl, `OLD.${key}`, "delete", ` WHERE OLD.${key} IS NOT NEW.${key}`)} ${record(tbl, `NEW.${key}`, "put")} END`,
|
|
281
|
+
`CREATE TRIGGER IF NOT EXISTS ${d} AFTER DELETE ON ${on} BEGIN ${record(tbl, `OLD.${key}`, "delete")} END`
|
|
282
|
+
];
|
|
283
|
+
};
|
|
284
|
+
function syncedSql(ctx, opts = {}) {
|
|
285
|
+
const { maxTombstones = 1e3, maxInlineChanges = 500, maxPullChanges = 5e3, onChange } = opts;
|
|
286
|
+
const storage = ctx.storage;
|
|
287
|
+
const sql = storage.sql;
|
|
288
|
+
const rows = (query, ...params) => sql.exec(query, ...params).toArray();
|
|
289
|
+
const count = (query, ...params) => sql.exec(query, ...params).one().n;
|
|
290
|
+
const meta = () => sql.exec("SELECT * FROM _sl_meta").one();
|
|
291
|
+
const signature = () => sql.exec(SIGNATURE_SQL).one().s;
|
|
292
|
+
const totalChanges = () => count("SELECT total_changes() AS n");
|
|
293
|
+
const userTables = () => {
|
|
294
|
+
const tables = /* @__PURE__ */ new Map();
|
|
295
|
+
for (const t of rows(TABLES_SQL)) {
|
|
296
|
+
tables.set(t.name, t.pks === 1 && t.pk !== null ? ident(t.pk) : t.wr === 0 ? "rowid" : void 0);
|
|
297
|
+
}
|
|
298
|
+
return tables;
|
|
299
|
+
};
|
|
300
|
+
const tracked = () => {
|
|
301
|
+
const tables = /* @__PURE__ */ new Map();
|
|
302
|
+
for (const { name, tbl, sql: create } of rows(TRACKED_SQL))
|
|
303
|
+
if (name === triggerName("i", tbl)) tables.set(tbl, create);
|
|
304
|
+
return tables;
|
|
305
|
+
};
|
|
306
|
+
const sync = (before, backfillAt) => {
|
|
307
|
+
for (const { name, tbl } of rows(TRIGGERS_SQL)) {
|
|
308
|
+
if (/^_sl_[iud]_/.test(name) && !OPS.some((op) => triggerName(op, tbl) === name)) sql.exec(`DROP TRIGGER ${ident(name)}`);
|
|
309
|
+
}
|
|
310
|
+
const now = tracked();
|
|
311
|
+
const lost = [...before].filter(([t, create]) => now.get(t) !== create).map(([t]) => t);
|
|
312
|
+
for (const t of lost) sql.exec("DELETE FROM _sl_changes WHERE tbl = ?", t);
|
|
313
|
+
for (const [table, key] of userTables()) {
|
|
314
|
+
if (now.has(table)) continue;
|
|
315
|
+
if (key === void 0) {
|
|
316
|
+
log.warning("Table {table} has a composite primary key and no rowid; its changes are not synced", { table });
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
for (const trigger of triggers(table, key)) sql.exec(trigger);
|
|
320
|
+
if (backfillAt !== void 0 && lost.length === 0)
|
|
321
|
+
sql.exec(`INSERT INTO _sl_changes (tbl, id, clock, op) SELECT ?, ${key}, ?, 'put' FROM ${ident(table)}`, table, backfillAt);
|
|
322
|
+
}
|
|
323
|
+
return lost.length > 0;
|
|
324
|
+
};
|
|
325
|
+
const ensure = () => {
|
|
326
|
+
sql.exec("PRAGMA recursive_triggers = ON");
|
|
327
|
+
if (rows(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_sl_meta'`).length > 0) return;
|
|
328
|
+
storage.transactionSync(() => {
|
|
329
|
+
for (const ddl of INSTALL) sql.exec(ddl);
|
|
330
|
+
sql.exec("INSERT INTO _sl_meta (epoch, clock, historyStartsAt) VALUES (?, 0, 0)", crypto.randomUUID());
|
|
331
|
+
sync(/* @__PURE__ */ new Map());
|
|
332
|
+
});
|
|
333
|
+
};
|
|
334
|
+
const read = (table, query, ...params) => {
|
|
335
|
+
const cursor = sql.exec(query, ...params);
|
|
336
|
+
const columns = cursor.columnNames.slice(1);
|
|
337
|
+
return Array.from(cursor.raw(), ([id, ...values]) => ({
|
|
338
|
+
table,
|
|
339
|
+
id: idOf(id),
|
|
340
|
+
data: Object.fromEntries(columns.map((c, i) => [c, encode2(values[i])]))
|
|
341
|
+
}));
|
|
342
|
+
};
|
|
343
|
+
const changes = (since) => {
|
|
344
|
+
const out = [];
|
|
345
|
+
for (const { tbl, id } of rows(`SELECT tbl, id FROM _sl_changes WHERE op = 'delete' AND clock > ?`, since))
|
|
346
|
+
out.push({ table: tbl, id: idOf(id), op: "delete" });
|
|
347
|
+
const tables = userTables();
|
|
348
|
+
for (const { tbl: table } of rows(`SELECT DISTINCT tbl FROM _sl_changes WHERE op = 'put' AND clock > ?`, since)) {
|
|
349
|
+
const key = tables.get(table);
|
|
350
|
+
if (key === void 0) continue;
|
|
351
|
+
const query = `SELECT t.${key}, t.* FROM ${ident(table)} AS t JOIN _sl_changes AS c ON c.id = t.${key}
|
|
352
|
+
WHERE c.tbl = ? AND c.op = 'put' AND c.clock > ?`;
|
|
353
|
+
for (const row of read(table, query, table, since)) out.push({ ...row, op: "put" });
|
|
354
|
+
}
|
|
355
|
+
return out;
|
|
356
|
+
};
|
|
357
|
+
const prune = () => {
|
|
358
|
+
const over = count(`SELECT count(*) AS n FROM _sl_changes WHERE op = 'delete'`) - maxTombstones;
|
|
359
|
+
if (over <= 0) return;
|
|
360
|
+
const cutoff = count(`SELECT clock AS n FROM _sl_changes WHERE op = 'delete' ORDER BY clock LIMIT 1 OFFSET ?`, over - 1);
|
|
361
|
+
sql.exec(`DELETE FROM _sl_changes WHERE op = 'delete' AND clock <= ?`, cutoff);
|
|
362
|
+
sql.exec("UPDATE _sl_meta SET historyStartsAt = max(historyStartsAt, ?)", cutoff);
|
|
363
|
+
};
|
|
364
|
+
const commit = (before) => {
|
|
365
|
+
const { epoch, clock } = meta();
|
|
366
|
+
const next = clock + 1;
|
|
367
|
+
const broken = signature() !== before.signature && sync(before.tracked, next);
|
|
368
|
+
const logged = count(`SELECT count(*) AS n FROM _sl_changes WHERE op IN ('put', 'delete') AND clock = ?`, next);
|
|
369
|
+
if (logged === 0 && !broken) return void 0;
|
|
370
|
+
sql.exec("UPDATE _sl_meta SET clock = ?", next);
|
|
371
|
+
if (broken) sql.exec("UPDATE _sl_meta SET historyStartsAt = ?", next);
|
|
372
|
+
prune();
|
|
373
|
+
if (!onChange) return void 0;
|
|
374
|
+
const inline = meta().historyStartsAt < next && logged <= maxInlineChanges;
|
|
375
|
+
return { epoch, clock: next, changes: inline ? changes(clock) : void 0 };
|
|
376
|
+
};
|
|
377
|
+
const notify = (push) => ctx.waitUntil(
|
|
378
|
+
Promise.resolve().then(() => onChange?.(push)).catch((error) => log.error("onChange failed for clock {clock}: {error}", { clock: push.clock, error }))
|
|
379
|
+
);
|
|
380
|
+
const run = ({ sql: query, params = [] }, budget) => {
|
|
381
|
+
const cursor = sql.exec(query, ...params.map(decode));
|
|
382
|
+
const out = [];
|
|
383
|
+
for (const raw of cursor.raw()) {
|
|
384
|
+
const row = raw.map(encode2);
|
|
385
|
+
budget.bytes += utf8.encode(JSON.stringify(row)).byteLength + 1;
|
|
386
|
+
if (budget.bytes > budget.maxBytes) throw new ResultTooLarge(budget.bytes);
|
|
387
|
+
out.push(row);
|
|
388
|
+
}
|
|
389
|
+
return { columns: cursor.columnNames, rows: out, meta: { rowsRead: cursor.rowsRead, rowsWritten: cursor.rowsWritten } };
|
|
390
|
+
};
|
|
391
|
+
return {
|
|
392
|
+
query(statements, { readOnly = false, maxBytes = MAX_BYTES } = {}) {
|
|
393
|
+
let index = 0;
|
|
394
|
+
const budget = { bytes: 0, maxBytes };
|
|
395
|
+
try {
|
|
396
|
+
ensure();
|
|
397
|
+
const { results, push } = storage.transactionSync(() => {
|
|
398
|
+
const before = readOnly ? void 0 : { signature: signature(), tracked: tracked() };
|
|
399
|
+
const results2 = statements.map((statement, i) => {
|
|
400
|
+
index = i;
|
|
401
|
+
if (!readOnly) return run(statement, budget);
|
|
402
|
+
const schema = signature();
|
|
403
|
+
const changed = totalChanges();
|
|
404
|
+
const result = run(statement, budget);
|
|
405
|
+
if (result.meta.rowsWritten > 0 || totalChanges() !== changed || signature() !== schema) throw new Error(READ_ONLY_MESSAGE);
|
|
406
|
+
return result;
|
|
407
|
+
});
|
|
408
|
+
return { results: results2, push: before && commit(before) };
|
|
409
|
+
});
|
|
410
|
+
if (push) notify(push);
|
|
411
|
+
return { results };
|
|
412
|
+
} catch (error) {
|
|
413
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
414
|
+
if (error instanceof ResultTooLarge) return { error: message, statement: index, bytes: error.bytes };
|
|
415
|
+
return { error: message, statement: index };
|
|
416
|
+
}
|
|
417
|
+
},
|
|
418
|
+
snapshot(table, limit) {
|
|
419
|
+
ensure();
|
|
420
|
+
const key = userTables().get(table);
|
|
421
|
+
if (key === void 0) return [];
|
|
422
|
+
return read(table, `SELECT ${key}, * FROM ${ident(table)} LIMIT ?`, limit);
|
|
423
|
+
},
|
|
424
|
+
get(table, id) {
|
|
425
|
+
ensure();
|
|
426
|
+
const key = userTables().get(table);
|
|
427
|
+
if (key === void 0) return void 0;
|
|
428
|
+
return read(table, `SELECT ${key}, * FROM ${ident(table)} WHERE ${key} = ?`, id).find((row) => row.id === id);
|
|
429
|
+
},
|
|
430
|
+
changesSince(since) {
|
|
431
|
+
ensure();
|
|
432
|
+
const { epoch, clock, historyStartsAt } = meta();
|
|
433
|
+
const wipeAll = since < historyStartsAt || since > clock || count(`SELECT count(*) AS n FROM _sl_changes WHERE op IN ('put', 'delete') AND clock > ?`, since) > maxPullChanges;
|
|
434
|
+
return { epoch, clock, changes: wipeAll ? [] : changes(since), wipeAll };
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// src/remote-rows.ts
|
|
440
|
+
import { SuperLineError, applyQuery } from "@super-line/core";
|
|
441
|
+
var CURRENT = Number.MAX_SAFE_INTEGER;
|
|
442
|
+
var conjuncts = (expr) => expr === void 0 ? [] : expr.op === "and" ? expr.args.flatMap(conjuncts) : [expr];
|
|
443
|
+
var pinOf = (parts, field) => {
|
|
444
|
+
for (const e of parts) if (e.op === "eq" && e.field === field) return e.value;
|
|
445
|
+
return void 0;
|
|
446
|
+
};
|
|
447
|
+
var FIXED = ["storeId", "table"];
|
|
448
|
+
var isPin = (e) => e.op === "eq" && (e.field === "storeId" || e.field === "table");
|
|
449
|
+
var splitKey = (key) => {
|
|
450
|
+
const a = key.indexOf(":");
|
|
451
|
+
const b = key.indexOf(":", a + 1);
|
|
452
|
+
return a < 0 || b < 0 ? void 0 : [key.slice(0, a), key.slice(a + 1, b), key.slice(b + 1)];
|
|
453
|
+
};
|
|
454
|
+
function remoteRows(opts) {
|
|
455
|
+
const { collection, storage, stub, onWipe, snapshotLimit = 1e3 } = opts;
|
|
456
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
457
|
+
const queues = /* @__PURE__ */ new Map();
|
|
458
|
+
const prefix = `remote-rows:${collection}:`;
|
|
459
|
+
const stateKey = (storeId) => prefix + storeId;
|
|
460
|
+
const keyOf = (storeId, table, id) => `${storeId}:${table}:${id}`;
|
|
461
|
+
const rowOf = (storeId, { table, id, data }) => ({ key: keyOf(storeId, table, id), storeId, table, id, data });
|
|
462
|
+
const changeOf = (storeId, c) => {
|
|
463
|
+
const id = keyOf(storeId, c.table, c.id);
|
|
464
|
+
if (c.op === "delete") return { n: collection, k: "delete", id, origin: storeId };
|
|
465
|
+
return { n: collection, k: "update", id, next: rowOf(storeId, c), origin: storeId };
|
|
466
|
+
};
|
|
467
|
+
const emit = (storeId, changes) => {
|
|
468
|
+
for (const c of changes) {
|
|
469
|
+
const change = changeOf(storeId, c);
|
|
470
|
+
for (const cb of listeners) cb(change);
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
const serial = (storeId, step) => {
|
|
474
|
+
const run = (queues.get(storeId) ?? Promise.resolve()).then(step);
|
|
475
|
+
queues.set(storeId, run.catch(() => {
|
|
476
|
+
}));
|
|
477
|
+
return run;
|
|
478
|
+
};
|
|
479
|
+
const save = (storeId, { epoch, clock }) => storage.put(stateKey(storeId), { epoch, clock });
|
|
480
|
+
async function pull(storeId, seen) {
|
|
481
|
+
const pulled = await stub(storeId).changesSince(seen?.clock ?? CURRENT);
|
|
482
|
+
await save(storeId, pulled);
|
|
483
|
+
if (!seen) return;
|
|
484
|
+
if (pulled.wipeAll || pulled.epoch !== seen.epoch) await onWipe?.(storeId);
|
|
485
|
+
else emit(storeId, pulled.changes);
|
|
486
|
+
}
|
|
487
|
+
async function receive(storeId, push) {
|
|
488
|
+
const seen = await storage.get(stateKey(storeId));
|
|
489
|
+
if (!seen) return;
|
|
490
|
+
const sameEpoch = push.epoch === seen.epoch;
|
|
491
|
+
if (sameEpoch && push.clock <= seen.clock) return;
|
|
492
|
+
if (!sameEpoch || push.clock !== seen.clock + 1 || !push.changes) return pull(storeId, seen);
|
|
493
|
+
await save(storeId, push);
|
|
494
|
+
emit(storeId, push.changes);
|
|
495
|
+
}
|
|
496
|
+
const catchUp = (storeId) => serial(storeId, async () => pull(storeId, await storage.get(stateKey(storeId))));
|
|
497
|
+
const readOnly = () => {
|
|
498
|
+
throw new SuperLineError("FORBIDDEN", `Collection ${collection} is read-only: write through its source`);
|
|
499
|
+
};
|
|
500
|
+
return {
|
|
501
|
+
clustering: "self",
|
|
502
|
+
coordination: "cluster",
|
|
503
|
+
ingest: (storeId, push) => serial(storeId, () => receive(storeId, push)),
|
|
504
|
+
async resume(storeIds) {
|
|
505
|
+
const ids = storeIds ?? [...(await storage.list({ prefix })).keys()].map((key) => key.slice(prefix.length));
|
|
506
|
+
await Promise.all(ids.map(catchUp));
|
|
507
|
+
},
|
|
508
|
+
async snapshot(_n, query) {
|
|
509
|
+
const parts = conjuncts(query.filter);
|
|
510
|
+
const storeId = pinOf(parts, "storeId");
|
|
511
|
+
const table = pinOf(parts, "table");
|
|
512
|
+
if (typeof storeId !== "string" || typeof table !== "string")
|
|
513
|
+
throw new SuperLineError("VALIDATION", `A ${collection} subscription must pin storeId and table with eq()`);
|
|
514
|
+
await catchUp(storeId);
|
|
515
|
+
const exact = !query.orderBy?.length && parts.every(isPin) && query.limit !== void 0;
|
|
516
|
+
const limit = exact ? Math.min(snapshotLimit, (query.offset ?? 0) + (query.limit ?? 0)) : snapshotLimit;
|
|
517
|
+
const rows = await stub(storeId).snapshot(table, limit);
|
|
518
|
+
return applyQuery(rows.map((row) => rowOf(storeId, row)), query);
|
|
519
|
+
},
|
|
520
|
+
async read(_n, key) {
|
|
521
|
+
const parts = splitKey(key);
|
|
522
|
+
if (!parts) return void 0;
|
|
523
|
+
const [storeId, table, id] = parts;
|
|
524
|
+
const row = await stub(storeId).get(table, id);
|
|
525
|
+
return row && rowOf(storeId, row);
|
|
526
|
+
},
|
|
527
|
+
fixedFields: () => FIXED,
|
|
528
|
+
onChange(cb) {
|
|
529
|
+
listeners.add(cb);
|
|
530
|
+
return () => listeners.delete(cb);
|
|
531
|
+
},
|
|
532
|
+
apply: readOnly,
|
|
533
|
+
conditionalApply: readOnly
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// src/index.ts
|
|
538
|
+
var MAX_PARAMS = 100;
|
|
539
|
+
var MAX_COLUMNS = 98;
|
|
540
|
+
function cloudflareCollections(storage, collections) {
|
|
541
|
+
const sql = storage.sql;
|
|
542
|
+
const rows = (query, ...params) => Array.from(sql.exec(query, ...params));
|
|
543
|
+
const openTable = (n, def) => {
|
|
544
|
+
const t = defineTable(n, def);
|
|
545
|
+
if (t.plan.columns.length > MAX_COLUMNS)
|
|
546
|
+
throw new Error(`plans ${t.plan.columns.length} columns; a Durable Object table holds ${MAX_COLUMNS} besides the row timestamps`);
|
|
547
|
+
sql.exec(t.create);
|
|
548
|
+
const stored = rows(META_SQL.get, n)[0]?.fingerprint;
|
|
549
|
+
if (stored === void 0) sql.exec(META_SQL.insert, n, t.plan.fingerprint);
|
|
550
|
+
else if (stored !== t.plan.fingerprint) {
|
|
551
|
+
const drift = driftOf(t, stored);
|
|
552
|
+
if ("refuse" in drift)
|
|
553
|
+
throw new Error(`schema ${drift.refuse} \u2014 rename the collection, or migrate table "${t.name}" and its col_meta row in code`);
|
|
554
|
+
for (const alter of drift.alter) sql.exec(alter);
|
|
555
|
+
sql.exec(META_SQL.update, t.plan.fingerprint, n);
|
|
556
|
+
}
|
|
557
|
+
for (const index of t.indexes) sql.exec(index);
|
|
558
|
+
return t;
|
|
559
|
+
};
|
|
560
|
+
let tables;
|
|
561
|
+
const open = () => {
|
|
562
|
+
if (tables) return tables;
|
|
563
|
+
sql.exec(META_SQL.create);
|
|
564
|
+
tables = /* @__PURE__ */ new Map();
|
|
565
|
+
for (const [n, def] of Object.entries(collections)) {
|
|
566
|
+
if (isCrdtCollection(def)) continue;
|
|
567
|
+
try {
|
|
568
|
+
tables.set(n, openTable(n, def));
|
|
569
|
+
} catch (err) {
|
|
570
|
+
tables.set(n, new SuperLineError2("INTERNAL", `cloudflareCollections: collection '${n}' is unusable: ${err.message}`));
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return tables;
|
|
574
|
+
};
|
|
575
|
+
const table = (n) => {
|
|
576
|
+
const t = open().get(n);
|
|
577
|
+
if (t instanceof SuperLineError2) throw t;
|
|
578
|
+
return t;
|
|
579
|
+
};
|
|
580
|
+
const readRow = (t, id) => {
|
|
581
|
+
const r = rows(t.sql.get, id)[0];
|
|
582
|
+
return r ? fromRow(t, r) : void 0;
|
|
583
|
+
};
|
|
584
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
585
|
+
const emit = (changes) => {
|
|
586
|
+
for (const c of changes) for (const cb of listeners) cb(c);
|
|
587
|
+
};
|
|
588
|
+
const applyOps = (ops, origin) => {
|
|
589
|
+
const changes = [];
|
|
590
|
+
const ts = Date.now();
|
|
591
|
+
for (const op of ops) {
|
|
592
|
+
const t = table(op.n);
|
|
593
|
+
if (!t) throw new SuperLineError2("NOT_FOUND", `Unknown collection: ${op.n}`);
|
|
594
|
+
if (op.op === "insert") {
|
|
595
|
+
if (rows(t.sql.has, op.id).length > 0) throw new SuperLineError2("CONFLICT", `Row already exists: ${op.n}/${op.id}`);
|
|
596
|
+
sql.exec(t.sql.insert, ...rowParams(t, op.id, op.row), ts, ts);
|
|
597
|
+
changes.push({ n: op.n, k: "insert", id: op.id, next: op.row, origin });
|
|
598
|
+
} else if (op.op === "update") {
|
|
599
|
+
const prev = readRow(t, op.id);
|
|
600
|
+
if (prev === void 0) throw new SuperLineError2("NOT_FOUND", `No row: ${op.n}/${op.id}`);
|
|
601
|
+
sql.exec(t.sql.update, ...updateParams(t, op.id, op.row, ts));
|
|
602
|
+
changes.push({ n: op.n, k: "update", id: op.id, prev, next: op.row, origin });
|
|
603
|
+
} else {
|
|
604
|
+
const prev = readRow(t, op.id);
|
|
605
|
+
if (prev === void 0) continue;
|
|
606
|
+
sql.exec(t.sql.delete, op.id);
|
|
607
|
+
changes.push({ n: op.n, k: "delete", id: op.id, prev, origin });
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return changes;
|
|
611
|
+
};
|
|
612
|
+
return {
|
|
613
|
+
clustering: "relay",
|
|
614
|
+
coordination: "local",
|
|
615
|
+
apply(ops, origin) {
|
|
616
|
+
const changes = storage.transactionSync(() => applyOps(ops, origin));
|
|
617
|
+
emit(changes);
|
|
618
|
+
return changes;
|
|
619
|
+
},
|
|
620
|
+
conditionalApply(conditions, ops, origin) {
|
|
621
|
+
const changes = storage.transactionSync(() => {
|
|
622
|
+
for (const condition of conditions) {
|
|
623
|
+
const t = table(condition.n);
|
|
624
|
+
const row = t ? readRow(t, condition.id) : void 0;
|
|
625
|
+
if (row === void 0 || !matchesFilter(condition.filter, row)) return void 0;
|
|
626
|
+
}
|
|
627
|
+
return applyOps(ops, origin);
|
|
628
|
+
});
|
|
629
|
+
if (!changes) return false;
|
|
630
|
+
emit(changes);
|
|
631
|
+
return true;
|
|
632
|
+
},
|
|
633
|
+
snapshot(n, query) {
|
|
634
|
+
const t = table(n);
|
|
635
|
+
if (!t) return [];
|
|
636
|
+
const q = snapshotSql(t, query, MAX_PARAMS);
|
|
637
|
+
const found = rows(q.sql, ...q.params).map((r) => fromRow(t, r));
|
|
638
|
+
return q.exact ? found : applyQuery2(found, query);
|
|
639
|
+
},
|
|
640
|
+
read(n, id) {
|
|
641
|
+
const t = table(n);
|
|
642
|
+
return t ? readRow(t, id) : void 0;
|
|
643
|
+
},
|
|
644
|
+
rowMeta(n, ids) {
|
|
645
|
+
const out = {};
|
|
646
|
+
const t = table(n);
|
|
647
|
+
if (!t) return out;
|
|
648
|
+
for (let i = 0; i < ids.length; i += MAX_PARAMS) {
|
|
649
|
+
const chunk = ids.slice(i, i + MAX_PARAMS);
|
|
650
|
+
for (const r of rows(rowMetaSql(t, chunk.length), ...chunk)) {
|
|
651
|
+
out[r.id] = { createdAt: r.c, updatedAt: r.u };
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
return out;
|
|
655
|
+
},
|
|
656
|
+
onChange(cb) {
|
|
657
|
+
listeners.add(cb);
|
|
658
|
+
return () => listeners.delete(cb);
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
export {
|
|
663
|
+
READ_ONLY_MESSAGE,
|
|
664
|
+
cloudflareCollections,
|
|
665
|
+
remoteRows,
|
|
666
|
+
syncedSql
|
|
667
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@super-line/collections-cloudflare",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Durable Object SQLite CollectionStore backend for super-line — typed row collections stored in the object that is the node, with IR→SQL snapshot pushdown.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Mert",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"super-line",
|
|
10
|
+
"collections",
|
|
11
|
+
"cloudflare",
|
|
12
|
+
"durable-objects",
|
|
13
|
+
"sqlite",
|
|
14
|
+
"rows"
|
|
15
|
+
],
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/mertdogar/super-line.git",
|
|
19
|
+
"directory": "packages/collections-cloudflare"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://mertdogar.github.io/super-line/",
|
|
22
|
+
"bugs": "https://github.com/mertdogar/super-line/issues",
|
|
23
|
+
"module": "./dist/index.js",
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"default": "./dist/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./contract": {
|
|
31
|
+
"types": "./dist/contract.d.ts",
|
|
32
|
+
"default": "./dist/contract.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist",
|
|
37
|
+
"CHANGELOG.md"
|
|
38
|
+
],
|
|
39
|
+
"sideEffects": false,
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@logtape/logtape": "^2.2.4",
|
|
45
|
+
"zod": "^4.2.0"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"@super-line/core": ">=0.23.0 <1.0.0"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@cloudflare/workers-types": "^5.20261013.1",
|
|
52
|
+
"@super-line/core": "^0.23.0"
|
|
53
|
+
},
|
|
54
|
+
"scripts": {
|
|
55
|
+
"build": "tsup"
|
|
56
|
+
}
|
|
6
57
|
}
|