cursedbelt-server 4.26.1 → 4.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/server/activity/d1.d.ts +70 -0
- package/dist/server/activity/d1.js +68 -0
- package/dist/server/activity/migrations.d.ts +1 -3
- package/dist/server/activity/migrations.js +7 -32
- package/dist/server/activity/query.d.ts +123 -0
- package/dist/server/activity/query.js +171 -0
- package/dist/server/activity/schema.d.ts +13 -0
- package/dist/server/activity/schema.js +43 -0
- package/dist/server/activity/shareSource.d.ts +1 -1
- package/dist/server/activity/shareSource.js +1 -1
- package/dist/server/activity/store.d.ts +4 -62
- package/dist/server/activity/store.js +14 -145
- package/dist/server/engagement/d1.d.ts +67 -0
- package/dist/server/engagement/d1.js +81 -0
- package/dist/server/engagement/manifestPlaces.d.ts +9 -0
- package/dist/server/engagement/manifestPlaces.js +17 -0
- package/dist/server/engagement/places.js +2 -18
- package/dist/server/notifications/d1.d.ts +80 -0
- package/dist/server/notifications/d1.js +226 -0
- package/dist/server/notifications/service.d.ts +2 -20
- package/dist/server/notifications/types.d.ts +27 -0
- package/dist/server/notifications/types.js +1 -0
- package/dist/server/sharing/contract.d.ts +94 -0
- package/dist/server/sharing/contract.js +3 -0
- package/dist/server/sharing/d1.d.ts +77 -0
- package/dist/server/sharing/d1.js +309 -0
- package/dist/server/sharing/index.d.ts +1 -0
- package/dist/server/sharing/migrations.d.ts +1 -4
- package/dist/server/sharing/migrations.js +7 -72
- package/dist/server/sharing/router.d.ts +8 -4
- package/dist/server/sharing/router.js +7 -5
- package/dist/server/sharing/schema.d.ts +19 -0
- package/dist/server/sharing/schema.js +88 -0
- package/dist/server/sharing/sql.js +1 -1
- package/dist/server/sharing/statements.d.ts +116 -0
- package/dist/server/sharing/statements.js +139 -0
- package/dist/server/sharing/store.d.ts +3 -28
- package/dist/server/sharing/store.js +32 -105
- package/docs/activity.md +7 -0
- package/docs/engagement.md +6 -0
- package/docs/notifications.md +9 -0
- package/package.json +25 -1
- package/src/server/activity/d1.spec.ts +169 -0
- package/src/server/activity/d1.ts +172 -0
- package/src/server/activity/migrations.ts +6 -40
- package/src/server/activity/query.ts +283 -0
- package/src/server/activity/schema.ts +45 -0
- package/src/server/activity/shareSource.ts +2 -2
- package/src/server/activity/store.ts +45 -257
- package/src/server/engagement/d1.spec.ts +102 -0
- package/src/server/engagement/d1.ts +141 -0
- package/src/server/engagement/manifestPlaces.ts +24 -0
- package/src/server/engagement/places.ts +2 -16
- package/src/server/notifications/d1.spec.ts +179 -0
- package/src/server/notifications/d1.ts +334 -0
- package/src/server/notifications/service.ts +7 -23
- package/src/server/notifications/types.ts +29 -0
- package/src/server/sharing/contract.ts +109 -0
- package/src/server/sharing/d1.spec.ts +242 -0
- package/src/server/sharing/d1.ts +430 -0
- package/src/server/sharing/index.ts +3 -0
- package/src/server/sharing/migrations.ts +12 -85
- package/src/server/sharing/router.ts +21 -11
- package/src/server/sharing/schema.ts +90 -0
- package/src/server/sharing/sql.ts +1 -1
- package/src/server/sharing/statements.ts +213 -0
- package/src/server/sharing/store.ts +46 -221
- package/src/server/telemetryIsWorkerSafe.spec.ts +64 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cursedbelt-server/notifications/d1` — the notification service over D1, for an app a Worker
|
|
3
|
+
* serves.
|
|
4
|
+
*
|
|
5
|
+
* ── Why it exists ───────────────────────────────────────────────────────────
|
|
6
|
+
* `createNotificationService` builds cwip's `createSqliteNotificationStore` over a synchronous
|
|
7
|
+
* `bun:sqlite` handle; a Worker has neither. `apps/family`'s port to a Worker + D1 (task 393)
|
|
8
|
+
* needs the SAME inbox — the same table, the same coalescing, the same routes — so this is
|
|
9
|
+
* cwip's `NotificationStore` contract implemented over D1, handed to cwip's own
|
|
10
|
+
* `createNotificationCenter`, and mounted by the same `mountNotificationRoutes`. The engine
|
|
11
|
+
* stays cwip's; only the storage changes.
|
|
12
|
+
*
|
|
13
|
+
* ── What is the same, and what is not ─────────────────────────────────────
|
|
14
|
+
* · The table is `createSqliteNotificationStore`'s, column for column and index for index
|
|
15
|
+
* ({@link notificationsDdl}; `./d1.spec.ts` compares the two schemas), so coalescing is
|
|
16
|
+
* still the partial unique index and never only a read.
|
|
17
|
+
* · Every store method runs cwip's statement, bound identically; `./d1.spec.ts` drives both
|
|
18
|
+
* stores through one scenario and compares every result and every row.
|
|
19
|
+
* · 🔴 `hub` is always `null` and `{base}/stream` is never mounted. Live delivery is an
|
|
20
|
+
* in-memory set of open streams, and a Worker isolate cannot share one with the isolate
|
|
21
|
+
* that produced the notification — a stream route that answered 200 and never emitted
|
|
22
|
+
* would be worse than the 404 the client already falls back from. The poll transport is
|
|
23
|
+
* the delivery story here, exactly as it is for `live: false` on the Mac.
|
|
24
|
+
* · 🔴 No DDL runs here, ever. The sync store migrates on construction; on D1 the table is
|
|
25
|
+
* the app's `db/schema.sql`, built from {@link notificationsDdl} (or from an in-memory run
|
|
26
|
+
* of `NOTIFICATION_MIGRATIONS` — the two converge, and the spec proves it).
|
|
27
|
+
*
|
|
28
|
+
* 🔴 Worker-safe: nothing reachable from here at runtime imports `bun:*`
|
|
29
|
+
* (`../telemetryIsWorkerSafe.spec.ts` walks it). Do not import `./service.js`,
|
|
30
|
+
* `./migrations.js` or `./index.js` here.
|
|
31
|
+
*
|
|
32
|
+
* ```ts
|
|
33
|
+
* import { createD1NotificationService } from "cursedbelt-server/notifications/d1";
|
|
34
|
+
*
|
|
35
|
+
* const notifications = createD1NotificationService({ db: createRemoteD1(env.DB) });
|
|
36
|
+
* notifications.mount(app, { authorize: (c) => sso.userOf(c)?.id ?? null });
|
|
37
|
+
* await notifications.center.notify(userId, { type: "share.grant", title: "Shared with you" });
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
import { createNotificationCenter, } from "cwip/notifications";
|
|
41
|
+
import { mountNotificationRoutes } from "./routes.js";
|
|
42
|
+
/** The default table — the name `createSqliteNotificationStore` and `NOTIFICATION_MIGRATIONS` use. */
|
|
43
|
+
export const D1_NOTIFICATIONS_TABLE = "notifications";
|
|
44
|
+
const DEFAULT_LIMIT = 50;
|
|
45
|
+
const assertIdentifier = (name) => {
|
|
46
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
47
|
+
throw new Error(`notifications: invalid table identifier: ${name}`);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* The DDL a D1 schema needs for the notification table — the FINAL shape
|
|
52
|
+
* `createSqliteNotificationStore` migrates any database to (its later `source`/`severity`
|
|
53
|
+
* columns are in the CREATE, as they are in cwip's), with both indexes. Idempotent.
|
|
54
|
+
*/
|
|
55
|
+
export function notificationsDdl(table = D1_NOTIFICATIONS_TABLE) {
|
|
56
|
+
assertIdentifier(table);
|
|
57
|
+
return [
|
|
58
|
+
`CREATE TABLE IF NOT EXISTS ${table} (
|
|
59
|
+
id TEXT PRIMARY KEY,
|
|
60
|
+
user_id TEXT NOT NULL,
|
|
61
|
+
created_at INTEGER NOT NULL,
|
|
62
|
+
updated_at INTEGER NOT NULL,
|
|
63
|
+
type TEXT NOT NULL,
|
|
64
|
+
source TEXT,
|
|
65
|
+
severity TEXT NOT NULL DEFAULT 'info',
|
|
66
|
+
title TEXT NOT NULL,
|
|
67
|
+
body TEXT,
|
|
68
|
+
link TEXT,
|
|
69
|
+
resource_type TEXT,
|
|
70
|
+
resource_id TEXT,
|
|
71
|
+
actor_id TEXT,
|
|
72
|
+
actor_name TEXT,
|
|
73
|
+
group_key TEXT,
|
|
74
|
+
count INTEGER NOT NULL DEFAULT 1,
|
|
75
|
+
read INTEGER NOT NULL DEFAULT 0,
|
|
76
|
+
done INTEGER NOT NULL DEFAULT 0
|
|
77
|
+
)`,
|
|
78
|
+
`CREATE INDEX IF NOT EXISTS idx_${table}_user ON ${table}(user_id, done, read, created_at)`,
|
|
79
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS idx_${table}_group ON ${table}(user_id, group_key)
|
|
80
|
+
WHERE group_key IS NOT NULL AND done = 0`,
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
/** {@link notificationsDdl} for the default table. */
|
|
84
|
+
export const NOTIFICATIONS_DDL = notificationsDdl();
|
|
85
|
+
/** cwip's `rowToNotification`, verbatim in effect — an absent column is `undefined`, never `null`. */
|
|
86
|
+
const rowToNotification = (row) => ({
|
|
87
|
+
id: row.id,
|
|
88
|
+
userId: row.user_id,
|
|
89
|
+
createdAt: row.created_at,
|
|
90
|
+
updatedAt: row.updated_at,
|
|
91
|
+
type: row.type,
|
|
92
|
+
source: row.source ?? undefined,
|
|
93
|
+
severity: row.severity ?? "info",
|
|
94
|
+
title: row.title,
|
|
95
|
+
body: row.body ?? undefined,
|
|
96
|
+
link: row.link ?? undefined,
|
|
97
|
+
resourceType: row.resource_type ?? undefined,
|
|
98
|
+
resourceId: row.resource_id ?? undefined,
|
|
99
|
+
actorId: row.actor_id ?? undefined,
|
|
100
|
+
actorName: row.actor_name ?? undefined,
|
|
101
|
+
groupKey: row.group_key ?? undefined,
|
|
102
|
+
count: row.count,
|
|
103
|
+
read: !!row.read,
|
|
104
|
+
done: !!row.done,
|
|
105
|
+
});
|
|
106
|
+
/**
|
|
107
|
+
* cwip's `NotificationStore` over D1 — the same statements as `createSqliteNotificationStore`,
|
|
108
|
+
* awaited. Every method is user-scoped, as the contract requires. Runs no DDL.
|
|
109
|
+
*/
|
|
110
|
+
export function createD1NotificationStore(db, options = {}) {
|
|
111
|
+
const table = options.table ?? D1_NOTIFICATIONS_TABLE;
|
|
112
|
+
assertIdentifier(table);
|
|
113
|
+
const insertSql = `
|
|
114
|
+
INSERT INTO ${table} (id, user_id, created_at, updated_at, type, source, severity, title,
|
|
115
|
+
body, link, resource_type, resource_id, actor_id, actor_name, group_key, count, read, done)
|
|
116
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
117
|
+
`;
|
|
118
|
+
const updateSql = `
|
|
119
|
+
UPDATE ${table} SET updated_at = ?, type = ?, source = ?, severity = ?, title = ?, body = ?,
|
|
120
|
+
link = ?, resource_type = ?, resource_id = ?, actor_id = ?, actor_name = ?, group_key = ?,
|
|
121
|
+
count = ?, read = ?, done = ?
|
|
122
|
+
WHERE id = ? AND user_id = ?
|
|
123
|
+
`;
|
|
124
|
+
/** 🔴 Every optional becomes `null` and every boolean `0/1` HERE — D1 refuses an
|
|
125
|
+
* `undefined` bind outright, where `bun:sqlite` would have bound NULL without a word. */
|
|
126
|
+
const bindDraftColumns = (n) => [
|
|
127
|
+
n.type,
|
|
128
|
+
n.source ?? null,
|
|
129
|
+
n.severity,
|
|
130
|
+
n.title,
|
|
131
|
+
n.body ?? null,
|
|
132
|
+
n.link ?? null,
|
|
133
|
+
n.resourceType ?? null,
|
|
134
|
+
n.resourceId ?? null,
|
|
135
|
+
n.actorId ?? null,
|
|
136
|
+
n.actorName ?? null,
|
|
137
|
+
n.groupKey ?? null,
|
|
138
|
+
n.count,
|
|
139
|
+
n.read ? 1 : 0,
|
|
140
|
+
n.done ? 1 : 0,
|
|
141
|
+
];
|
|
142
|
+
const one = async (sql, ...values) => {
|
|
143
|
+
const row = await db
|
|
144
|
+
.prepare(sql)
|
|
145
|
+
.bind(...values)
|
|
146
|
+
.first();
|
|
147
|
+
return row ? rowToNotification(row) : null;
|
|
148
|
+
};
|
|
149
|
+
const changes = async (sql, ...values) => (await db
|
|
150
|
+
.prepare(sql)
|
|
151
|
+
.bind(...values)
|
|
152
|
+
.run()).meta.changes;
|
|
153
|
+
return {
|
|
154
|
+
insert: async (n) => {
|
|
155
|
+
await db
|
|
156
|
+
.prepare(insertSql)
|
|
157
|
+
.bind(n.id, n.userId, n.createdAt, n.updatedAt, ...bindDraftColumns(n))
|
|
158
|
+
.run();
|
|
159
|
+
},
|
|
160
|
+
update: async (n) => {
|
|
161
|
+
await db
|
|
162
|
+
.prepare(updateSql)
|
|
163
|
+
.bind(n.updatedAt, ...bindDraftColumns(n), n.id, n.userId)
|
|
164
|
+
.run();
|
|
165
|
+
},
|
|
166
|
+
findOpenByGroup: (userId, groupKey) => one(`SELECT * FROM ${table} WHERE user_id = ? AND group_key = ? AND done = 0 LIMIT 1`, userId, groupKey),
|
|
167
|
+
get: (userId, id) => one(`SELECT * FROM ${table} WHERE id = ? AND user_id = ?`, id, userId),
|
|
168
|
+
list: async ({ userId, filter = "all", limit = DEFAULT_LIMIT, offset = 0, }) => {
|
|
169
|
+
const where = filter === "unread"
|
|
170
|
+
? "user_id = ? AND done = 0 AND read = 0"
|
|
171
|
+
: filter === "done"
|
|
172
|
+
? "user_id = ? AND done = 1"
|
|
173
|
+
: "user_id = ? AND done = 0";
|
|
174
|
+
// `rowid DESC` breaks a same-millisecond tie by insertion order, as cwip's store does —
|
|
175
|
+
// its comment has the measurement. D1 tables are rowid tables, so it means the same there.
|
|
176
|
+
const { results } = await db
|
|
177
|
+
.prepare(`SELECT * FROM ${table} WHERE ${where} ORDER BY created_at DESC, rowid DESC LIMIT ? OFFSET ?`)
|
|
178
|
+
.bind(userId, limit + 1, offset)
|
|
179
|
+
.all();
|
|
180
|
+
return { items: results.slice(0, limit).map(rowToNotification), hasMore: results.length > limit };
|
|
181
|
+
},
|
|
182
|
+
unreadCount: async (userId) => (await db
|
|
183
|
+
.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE user_id = ? AND done = 0 AND read = 0`)
|
|
184
|
+
.bind(userId)
|
|
185
|
+
.first("n")) ?? 0,
|
|
186
|
+
markRead: (userId, id, read) => changes(`UPDATE ${table} SET read = ? WHERE id = ? AND user_id = ? AND read != ?`, read ? 1 : 0, id, userId, read ? 1 : 0),
|
|
187
|
+
markAllRead: (userId) => changes(`UPDATE ${table} SET read = 1 WHERE user_id = ? AND done = 0 AND read = 0`, userId),
|
|
188
|
+
setDone: (userId, id, done) => changes(`UPDATE ${table} SET done = ?, read = CASE WHEN ? = 1 THEN 1 ELSE read END
|
|
189
|
+
WHERE id = ? AND user_id = ? AND done != ?`, done ? 1 : 0, done ? 1 : 0, id, userId, done ? 1 : 0),
|
|
190
|
+
doneAll: (userId) => changes(`UPDATE ${table} SET done = 1, read = 1 WHERE user_id = ? AND done = 0`, userId),
|
|
191
|
+
remove: (userId, id) => changes(`DELETE FROM ${table} WHERE id = ? AND user_id = ?`, id, userId),
|
|
192
|
+
pruneDone: (olderThanMs) => changes(`DELETE FROM ${table} WHERE done = 1 AND updated_at < ?`, olderThanMs),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* The whole server half over D1 — `createNotificationService`'s shape, with `hub: null`.
|
|
197
|
+
* `mount` hangs the same routes on the app, minus `{base}/stream` (see the header).
|
|
198
|
+
*/
|
|
199
|
+
export function createD1NotificationService(options) {
|
|
200
|
+
const onError = options.onError ??
|
|
201
|
+
((error, context) => console.error(`[notifications] ${context}:`, error instanceof Error ? error.message : error));
|
|
202
|
+
const store = createD1NotificationStore(options.db, { table: options.table });
|
|
203
|
+
const center = createNotificationCenter({
|
|
204
|
+
store,
|
|
205
|
+
resolveRole: options.resolveRole,
|
|
206
|
+
now: options.now,
|
|
207
|
+
newId: options.newId,
|
|
208
|
+
onError,
|
|
209
|
+
});
|
|
210
|
+
return {
|
|
211
|
+
center,
|
|
212
|
+
store,
|
|
213
|
+
hub: null,
|
|
214
|
+
mount(app, mountOptions) {
|
|
215
|
+
mountNotificationRoutes(app, {
|
|
216
|
+
center,
|
|
217
|
+
authorize: mountOptions.authorize,
|
|
218
|
+
basePath: mountOptions.basePath,
|
|
219
|
+
maxLimit: mountOptions.maxLimit,
|
|
220
|
+
});
|
|
221
|
+
},
|
|
222
|
+
close() {
|
|
223
|
+
// Nothing to close: there are no live streams on D1.
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
@@ -13,9 +13,8 @@
|
|
|
13
13
|
* import and no wiring to get wrong.
|
|
14
14
|
*/
|
|
15
15
|
import type { Database } from "bun:sqlite";
|
|
16
|
-
import
|
|
17
|
-
|
|
18
|
-
import { type NotificationHub } from "./hub.js";
|
|
16
|
+
import type { NotificationService } from "./types.js";
|
|
17
|
+
export type { NotificationService, NotificationServiceMountOptions } from "./types.js";
|
|
19
18
|
export interface CreateNotificationServiceOptions {
|
|
20
19
|
/** The app's own database — the table lands beside its rows. */
|
|
21
20
|
db: Database;
|
|
@@ -38,21 +37,4 @@ export interface CreateNotificationServiceOptions {
|
|
|
38
37
|
now?: () => number;
|
|
39
38
|
newId?: () => string;
|
|
40
39
|
}
|
|
41
|
-
export interface NotificationServiceMountOptions {
|
|
42
|
-
/** Resolve the caller to a user id, or null to refuse. */
|
|
43
|
-
authorize: (c: Context) => string | null | Promise<string | null>;
|
|
44
|
-
/** Path prefix. Default `/api/notifications`. */
|
|
45
|
-
basePath?: string;
|
|
46
|
-
/** Largest page a caller may ask for. Default 200. */
|
|
47
|
-
maxLimit?: number;
|
|
48
|
-
}
|
|
49
|
-
export interface NotificationService {
|
|
50
|
-
readonly center: NotificationCenter;
|
|
51
|
-
readonly store: NotificationStore;
|
|
52
|
-
/** Null when `live: false` — there is then nothing to stream. */
|
|
53
|
-
readonly hub: NotificationHub | null;
|
|
54
|
-
mount(app: Hono, options: NotificationServiceMountOptions): void;
|
|
55
|
-
/** Drop live subscribers (shutdown, tests). Safe to call with no hub. */
|
|
56
|
-
close(): void;
|
|
57
|
-
}
|
|
58
40
|
export declare function createNotificationService(options: CreateNotificationServiceOptions): NotificationService;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The notification service's shape with no driver attached — what `createNotificationService`
|
|
3
|
+
* (`bun:sqlite`) and `createD1NotificationService` (D1, `./d1.ts`) both return. It lives apart
|
|
4
|
+
* from `./service.ts` so the D1 half, which runs on `workerd`, never reaches a module that
|
|
5
|
+
* names `bun:sqlite`, not even as a type.
|
|
6
|
+
*/
|
|
7
|
+
import type { NotificationCenter, NotificationStore } from "cwip/notifications";
|
|
8
|
+
import type { Context, Hono } from "hono";
|
|
9
|
+
import type { NotificationHub } from "./hub.js";
|
|
10
|
+
export interface NotificationServiceMountOptions {
|
|
11
|
+
/** Resolve the caller to a user id, or null to refuse. */
|
|
12
|
+
authorize: (c: Context) => string | null | Promise<string | null>;
|
|
13
|
+
/** Path prefix. Default `/api/notifications`. */
|
|
14
|
+
basePath?: string;
|
|
15
|
+
/** Largest page a caller may ask for. Default 200. */
|
|
16
|
+
maxLimit?: number;
|
|
17
|
+
}
|
|
18
|
+
export interface NotificationService {
|
|
19
|
+
readonly center: NotificationCenter;
|
|
20
|
+
readonly store: NotificationStore;
|
|
21
|
+
/** Null when `live: false`, and always on D1 (`./d1.ts`) — there is then nothing to stream,
|
|
22
|
+
* and the client's poll transport is the whole delivery story. */
|
|
23
|
+
readonly hub: NotificationHub | null;
|
|
24
|
+
mount(app: Hono, options: NotificationServiceMountOptions): void;
|
|
25
|
+
/** Drop live subscribers (shutdown, tests). Safe to call with no hub. */
|
|
26
|
+
close(): void;
|
|
27
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The store's contract with no driver attached: the input shapes both stores take, the
|
|
3
|
+
* {@link AsyncShareStore} a D1-backed store satisfies, and the two subject helpers.
|
|
4
|
+
*
|
|
5
|
+
* It lives apart from `./store.ts` so that `./d1.ts` — which runs on `workerd` — and its
|
|
6
|
+
* declaration file never reach a module that names `bun:sqlite`, not even as a type. The
|
|
7
|
+
* sync store re-exports everything here, so `cursedbelt-server/sharing` is unchanged.
|
|
8
|
+
*/
|
|
9
|
+
import type { ShareAccess, ShareEvent, ShareGrant, ShareGroup, ShareGroupMember, ShareLevel, ShareSubject, ShareViewer } from "cursedbelt-core/sharing/model";
|
|
10
|
+
/** Shorthand for the common subject. `group(id)` is its sibling. */
|
|
11
|
+
export declare const user: (id: string) => ShareSubject;
|
|
12
|
+
export declare const group: (id: string) => ShareSubject;
|
|
13
|
+
/** A resource within the store's own app — the `app` half is supplied by the
|
|
14
|
+
* store, so a caller cannot mistakenly write a grant into another app's rows. */
|
|
15
|
+
export interface ShareTarget {
|
|
16
|
+
kind: string;
|
|
17
|
+
id: string;
|
|
18
|
+
}
|
|
19
|
+
export interface GrantInput extends ShareTarget {
|
|
20
|
+
subject: ShareSubject;
|
|
21
|
+
level: ShareLevel;
|
|
22
|
+
grantedBy: string;
|
|
23
|
+
note?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface RevokeInput extends ShareTarget {
|
|
26
|
+
subject: ShareSubject;
|
|
27
|
+
revokedBy: string;
|
|
28
|
+
note?: string;
|
|
29
|
+
}
|
|
30
|
+
export interface ShareEventQuery {
|
|
31
|
+
kind?: string;
|
|
32
|
+
id?: string;
|
|
33
|
+
subject?: ShareSubject;
|
|
34
|
+
/** Newest first, capped. Default 200 — a panel shows a page, and an unbounded
|
|
35
|
+
* audit read on a busy resource is the query that times out in production. */
|
|
36
|
+
limit?: number;
|
|
37
|
+
}
|
|
38
|
+
/** What `sharedSql` takes — the store supplies `app`. */
|
|
39
|
+
export interface SharedSqlInput {
|
|
40
|
+
kind: string;
|
|
41
|
+
subjectId: string;
|
|
42
|
+
column: string;
|
|
43
|
+
atLeast?: ShareLevel;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The share store over an ASYNC database — `createD1ShareStore` in
|
|
47
|
+
* `cursedbelt-server/sharing/d1`.
|
|
48
|
+
*
|
|
49
|
+
* The same methods as the sync `ShareStore`, with the same names, arguments and meaning;
|
|
50
|
+
* every method that touches the database resolves instead of returning. `sharedSql` stays
|
|
51
|
+
* synchronous because it touches nothing: it emits the SAME fragment the sync store does, for
|
|
52
|
+
* an app to embed in its own D1 queries. `sharing/d1.spec.ts` checks the two stores expose
|
|
53
|
+
* exactly the same method names, so neither can grow a verb the other lacks.
|
|
54
|
+
*/
|
|
55
|
+
export interface AsyncShareStore {
|
|
56
|
+
/** The app this store speaks for. */
|
|
57
|
+
readonly app: string;
|
|
58
|
+
grant(input: GrantInput): Promise<ShareGrant>;
|
|
59
|
+
revoke(input: RevokeInput): Promise<ShareLevel | null>;
|
|
60
|
+
revokeResource(target: ShareTarget, revokedBy: string): Promise<number>;
|
|
61
|
+
revokeSubject(subject: ShareSubject, revokedBy: string): Promise<number>;
|
|
62
|
+
listForResource(target: ShareTarget): Promise<ShareGrant[]>;
|
|
63
|
+
listForSubject(subject: ShareSubject): Promise<ShareGrant[]>;
|
|
64
|
+
accessFor(target: ShareTarget, viewer: ShareViewer): Promise<ShareAccess>;
|
|
65
|
+
resourceIdsFor(kind: string, viewer: ShareViewer, atLeast?: ShareLevel): Promise<string[]>;
|
|
66
|
+
sharedSql(input: SharedSqlInput): string;
|
|
67
|
+
createGroup(input: {
|
|
68
|
+
id: string;
|
|
69
|
+
name: string;
|
|
70
|
+
createdBy: string;
|
|
71
|
+
note?: string;
|
|
72
|
+
}): Promise<ShareGroup>;
|
|
73
|
+
renameGroup(id: string, name: string, actor: string): Promise<void>;
|
|
74
|
+
deleteGroup(id: string, actor: string): Promise<void>;
|
|
75
|
+
listGroups(): Promise<ShareGroup[]>;
|
|
76
|
+
addGroupMember(groupId: string, subjectId: string, actor: string): Promise<void>;
|
|
77
|
+
removeGroupMember(groupId: string, subjectId: string, actor: string): Promise<void>;
|
|
78
|
+
listGroupMembers(groupId: string): Promise<ShareGroupMember[]>;
|
|
79
|
+
groupIdsFor(subjectId: string): Promise<string[]>;
|
|
80
|
+
events(query?: ShareEventQuery): Promise<ShareEvent[]>;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* A value that may or may not be a promise — what a consumer holding EITHER store gets back
|
|
84
|
+
* from a call, and what it can always `await`.
|
|
85
|
+
*/
|
|
86
|
+
export type Awaitable<T> = T | Promise<T>;
|
|
87
|
+
/**
|
|
88
|
+
* Either store, as a consumer that works over both sees it: every method's result is
|
|
89
|
+
* {@link Awaitable}. `mountShareRoutes` takes this, and awaits every call — awaiting a
|
|
90
|
+
* synchronous value is a no-op, so the sync store's behaviour through it is unchanged.
|
|
91
|
+
*/
|
|
92
|
+
export type AwaitableShareStore = {
|
|
93
|
+
readonly [K in keyof AsyncShareStore]: AsyncShareStore[K] extends (...args: infer A) => Promise<infer R> ? (...args: A) => Awaitable<R> : AsyncShareStore[K];
|
|
94
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cursedbelt-server/sharing/d1` — the fleet's sharing model over D1, for an app a Worker serves.
|
|
3
|
+
*
|
|
4
|
+
* ── Why it exists ───────────────────────────────────────────────────────────
|
|
5
|
+
* `./store.ts` is synchronous `bun:sqlite`, and a Worker has neither. `apps/family`'s port to
|
|
6
|
+
* a Worker + D1 (task 393) needs the SAME grants, revokes, groups and audit trail, in the SAME
|
|
7
|
+
* four tables, so a database moved from the Mac to D1 keeps meaning what it meant. This is that
|
|
8
|
+
* store, beside the sync one — which stays exactly as it was, because `auth` and `station` are
|
|
9
|
+
* still Bun servers.
|
|
10
|
+
*
|
|
11
|
+
* ── What is the same, and what is not ─────────────────────────────────────
|
|
12
|
+
* Same methods, names, arguments, validation and results as `ShareStore`; every method that
|
|
13
|
+
* touches the database resolves instead of returning. `sharedSql` is still synchronous and
|
|
14
|
+
* emits the identical fragment, for an app to embed in its own D1 queries. The SQL is the
|
|
15
|
+
* sync store's own (`./statements.ts`), and `./d1.spec.ts` runs both stores through one
|
|
16
|
+
* scenario and compares every call's result and every table's rows.
|
|
17
|
+
*
|
|
18
|
+
* The four properties of `./store.ts`'s header hold, by different means:
|
|
19
|
+
* 1. **Every mutation writes its audit row in the same transaction.** D1 has no interactive
|
|
20
|
+
* transaction, so each mutation is ONE `db.batch([...])` — atomic on D1, a real
|
|
21
|
+
* transaction locally. Where the sync store reads the grant and then decides what to log,
|
|
22
|
+
* this store logs FROM the grant row inside that batch (`INSERT … SELECT … RETURNING`),
|
|
23
|
+
* so there is no window between the read and the write for another request to use.
|
|
24
|
+
* 2. **A revoked grant's history survives it** — the event insert runs before the delete.
|
|
25
|
+
* 3. **`revokeResource` is one call** — two statements, one batch, however many grants.
|
|
26
|
+
* 4. **The hook cannot break a write.** `onEvent` runs after the batch commits; a throw — or
|
|
27
|
+
* a rejected promise, which a Worker's hook may well return — is caught and warned.
|
|
28
|
+
*
|
|
29
|
+
* Differences a caller can observe, each deliberate:
|
|
30
|
+
* · A multi-grant revoke (`revokeResource`, `revokeSubject`, `deleteGroup`) writes its
|
|
31
|
+
* events oldest-grant-first (`ORDER BY grant_id`); the sync store writes them in whatever
|
|
32
|
+
* order its SELECT happened to return. Same events, a defined order.
|
|
33
|
+
* · `onEvent` may return a promise, and the store AWAITS it before resolving — a Worker that
|
|
34
|
+
* returned before the hook settled could have it cut off with the isolate.
|
|
35
|
+
* · Validation failures REJECT rather than throw synchronously (they are async methods).
|
|
36
|
+
* · 🔴 No DDL runs here, ever. The sync store migrates on construction; on D1 the schema is
|
|
37
|
+
* the app's `db/schema.sql`, built from {@link SHARE_DDL} (the exact statements the sync
|
|
38
|
+
* migration runs), and applied by `wrangler d1 migrations`. A request-time `CREATE TABLE`
|
|
39
|
+
* is a write per invocation against the 1,000-query budget, for nothing.
|
|
40
|
+
*
|
|
41
|
+
* 🔴 Worker-safe: nothing reachable from here at runtime imports `bun:*`
|
|
42
|
+
* (`../telemetryIsWorkerSafe.spec.ts` walks it). Do not import `./store.js` or
|
|
43
|
+
* `./migrations.js` here — `./schema.js`, `./statements.js` and `./contract.js` are the
|
|
44
|
+
* driver-free halves they were split into.
|
|
45
|
+
*
|
|
46
|
+
* ```ts
|
|
47
|
+
* import { createD1ShareStore } from "cursedbelt-server/sharing/d1";
|
|
48
|
+
*
|
|
49
|
+
* const shares = createD1ShareStore({ db: createRemoteD1(env.DB), app: "family", onEvent });
|
|
50
|
+
* await shares.grant({ kind: "person", id, subject: user(email), level: "read", grantedBy });
|
|
51
|
+
* const where = shares.sharedSql({ kind: "person", subjectId: viewer.email, column: "p.id" });
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
import { type ShareEvent } from "cursedbelt-core/sharing/model";
|
|
55
|
+
import type { D1LikeDatabase } from "../d1/types.js";
|
|
56
|
+
import type { AsyncShareStore } from "./contract.js";
|
|
57
|
+
export { group, user } from "./contract.js";
|
|
58
|
+
export type { AsyncShareStore, Awaitable, AwaitableShareStore, GrantInput, RevokeInput, SharedSqlInput, ShareEventQuery, ShareTarget, } from "./contract.js";
|
|
59
|
+
export { SHARE_DDL, SHARE_EVENTS_TABLE, SHARE_GRANTS_TABLE, SHARE_GROUPS_TABLE, SHARE_GROUP_MEMBERS_TABLE, } from "./schema.js";
|
|
60
|
+
export interface D1ShareStoreOptions {
|
|
61
|
+
/** The app's D1 handle — `createRemoteD1(env.DB)` on a Worker, `createLocalD1(sqlite)` in a test. */
|
|
62
|
+
db: D1LikeDatabase;
|
|
63
|
+
/** The satellite this store speaks for. Fixed at construction, as in the sync store. */
|
|
64
|
+
app: string;
|
|
65
|
+
/**
|
|
66
|
+
* Notified after every committed mutation, as in the sync store. May return a promise; the
|
|
67
|
+
* store awaits it, and neither a throw nor a rejection can undo the write.
|
|
68
|
+
*/
|
|
69
|
+
onEvent?: (event: ShareEvent) => void | Promise<void>;
|
|
70
|
+
/** Injectable clock, for tests that assert ordering. ISO-8601 strings. */
|
|
71
|
+
now?: () => string;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The share store over D1. Runs no DDL — see the header; the tables come from
|
|
75
|
+
* {@link SHARE_DDL} in the app's schema.
|
|
76
|
+
*/
|
|
77
|
+
export declare function createD1ShareStore(options: D1ShareStoreOptions): AsyncShareStore;
|