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,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every SQL statement the share stores run, and the row → shape mappers — ONE copy for both
|
|
3
|
+
* the `bun:sqlite` store (`./store.ts`) and the D1 store (`./d1.ts`).
|
|
4
|
+
*
|
|
5
|
+
* Two stores with two copies of "strongest first, then oldest" is two answers the day
|
|
6
|
+
* somebody edits one of them. So the stores differ only in HOW they execute (a synchronous
|
|
7
|
+
* transaction vs one atomic `batch()`), never in WHAT they ask. Driver-free on purpose: the
|
|
8
|
+
* D1 store runs on `workerd`.
|
|
9
|
+
*/
|
|
10
|
+
import type { ShareAction, ShareEvent, ShareGrant, ShareLevel } from "cursedbelt-core/sharing/model";
|
|
11
|
+
export interface GrantRow {
|
|
12
|
+
app: string;
|
|
13
|
+
kind: string;
|
|
14
|
+
resource_id: string;
|
|
15
|
+
subject_type: "user" | "group";
|
|
16
|
+
subject_id: string;
|
|
17
|
+
level: ShareLevel;
|
|
18
|
+
granted_by: string;
|
|
19
|
+
granted_at: string;
|
|
20
|
+
note: string;
|
|
21
|
+
}
|
|
22
|
+
export interface EventRow {
|
|
23
|
+
event_id: number;
|
|
24
|
+
at: string;
|
|
25
|
+
action: ShareAction;
|
|
26
|
+
app: string | null;
|
|
27
|
+
kind: string | null;
|
|
28
|
+
resource_id: string | null;
|
|
29
|
+
subject_type: "user" | "group";
|
|
30
|
+
subject_id: string;
|
|
31
|
+
level: ShareLevel | null;
|
|
32
|
+
previous_level: ShareLevel | null;
|
|
33
|
+
actor: string;
|
|
34
|
+
note: string;
|
|
35
|
+
}
|
|
36
|
+
export interface GroupRow {
|
|
37
|
+
group_id: string;
|
|
38
|
+
name: string;
|
|
39
|
+
created_by: string;
|
|
40
|
+
created_at: string;
|
|
41
|
+
note: string;
|
|
42
|
+
}
|
|
43
|
+
export interface GroupMemberRow {
|
|
44
|
+
group_id: string;
|
|
45
|
+
subject_id: string;
|
|
46
|
+
added_by: string;
|
|
47
|
+
added_at: string;
|
|
48
|
+
}
|
|
49
|
+
export declare const toGrant: (row: GrantRow) => ShareGrant;
|
|
50
|
+
export declare const toEvent: (row: EventRow) => ShareEvent;
|
|
51
|
+
export declare const SHARE_SQL: {
|
|
52
|
+
/** Binds the eleven {@link EVENT_COLUMNS}. */
|
|
53
|
+
readonly insertEvent: "INSERT INTO share_events\n\t\t\t (at, action, app, kind, resource_id, subject_type, subject_id, level, previous_level, actor, note)\n\t\t\t VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
|
54
|
+
readonly selectGrant: "SELECT * FROM share_grants WHERE app = ? AND kind = ? AND resource_id = ? AND subject_type = ? AND subject_id = ?";
|
|
55
|
+
/** Binds app, kind, resource_id, subject_type, subject_id, level, granted_by, granted_at, note. */
|
|
56
|
+
readonly upsertGrant: "INSERT INTO share_grants\n\t\t\t\t (app, kind, resource_id, subject_type, subject_id, level, granted_by, granted_at, note)\n\t\t\t\t VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\t\t\t ON CONFLICT (app, kind, resource_id, subject_type, subject_id)\n\t\t\t\t DO UPDATE SET level = excluded.level, granted_by = excluded.granted_by,\n\t\t\t\t granted_at = excluded.granted_at, note = excluded.note";
|
|
57
|
+
readonly deleteGrant: "DELETE FROM share_grants WHERE app = ? AND kind = ? AND resource_id = ? AND subject_type = ? AND subject_id = ?";
|
|
58
|
+
readonly selectResourceGrants: "SELECT * FROM share_grants WHERE app = ? AND kind = ? AND resource_id = ?";
|
|
59
|
+
readonly deleteResourceGrants: "DELETE FROM share_grants WHERE app = ? AND kind = ? AND resource_id = ?";
|
|
60
|
+
readonly selectSubjectGrants: "SELECT * FROM share_grants WHERE app = ? AND subject_type = ? AND subject_id = ?";
|
|
61
|
+
readonly deleteSubjectGrants: "DELETE FROM share_grants WHERE app = ? AND subject_type = ? AND subject_id = ?";
|
|
62
|
+
readonly selectGroupGrants: "SELECT * FROM share_grants WHERE app = ? AND subject_type = 'group' AND subject_id = ?";
|
|
63
|
+
readonly deleteGroupGrants: "DELETE FROM share_grants WHERE app = ? AND subject_type = 'group' AND subject_id = ?";
|
|
64
|
+
readonly listForResource: "SELECT * FROM share_grants\n\t\t\t\t\t WHERE app = ? AND kind = ? AND resource_id = ?\n\t\t\t\t\t -- Strongest first, then oldest. Spelled as a CASE rather than an\n\t\t\t\t\t -- ORDER BY on level, which happens to work only because 'write'\n\t\t\t\t\t -- sorts after 'read' and would silently mis-order a third level.\n\t\t\t\t\t ORDER BY CASE level WHEN 'write' THEN 0 ELSE 1 END, granted_at ASC";
|
|
65
|
+
readonly listForSubject: "SELECT * FROM share_grants\n\t\t\t\t\t WHERE app = ? AND subject_type = ? AND subject_id = ?\n\t\t\t\t\t ORDER BY granted_at DESC";
|
|
66
|
+
/** Binds app, kind, resource_id, then the viewer twice. */
|
|
67
|
+
readonly accessFor: "SELECT level FROM share_grants\n\t\t\t\t\t WHERE app = ? AND kind = ? AND resource_id = ?\n\t\t\t\t\t AND ( (subject_type = 'user' AND subject_id = ?)\n\t\t OR (subject_type = 'group' AND subject_id IN\n\t\t (SELECT group_id FROM share_group_members WHERE subject_id = ?)) )";
|
|
68
|
+
/** Binds app, kind, each of `levelCount` levels, then the viewer twice. */
|
|
69
|
+
readonly resourceIdsFor: (levelCount: number) => string;
|
|
70
|
+
readonly upsertGroup: "INSERT INTO share_groups (group_id, name, created_by, created_at, note)\n\t\t\t\t\t VALUES (?, ?, ?, ?, ?)\n\t\t\t\t\t ON CONFLICT (group_id) DO UPDATE SET name = excluded.name, note = excluded.note";
|
|
71
|
+
readonly renameGroup: "UPDATE share_groups SET name = ? WHERE group_id = ?";
|
|
72
|
+
readonly deleteGroup: "DELETE FROM share_groups WHERE group_id = ?";
|
|
73
|
+
readonly listGroups: "SELECT * FROM share_groups ORDER BY name";
|
|
74
|
+
readonly insertMember: "INSERT INTO share_group_members (group_id, subject_id, added_by, added_at)\n\t\t\t\t\t VALUES (?, ?, ?, ?) ON CONFLICT (group_id, subject_id) DO NOTHING";
|
|
75
|
+
readonly deleteMember: "DELETE FROM share_group_members WHERE group_id = ? AND subject_id = ?";
|
|
76
|
+
readonly listMembers: "SELECT * FROM share_group_members WHERE group_id = ? ORDER BY subject_id";
|
|
77
|
+
readonly groupIdsFor: "SELECT group_id FROM share_group_members WHERE subject_id = ? ORDER BY group_id";
|
|
78
|
+
/** The trail, newest first, under a WHERE built by {@link eventsWhere}; binds its params then the limit. */
|
|
79
|
+
readonly events: (where: string) => string;
|
|
80
|
+
/**
|
|
81
|
+
* The grant's event, read from the grant as it stands BEFORE the upsert that follows it in
|
|
82
|
+
* the same batch: `regrade` (with the old level) when the level changes, `grant` otherwise.
|
|
83
|
+
* Binds at, level, app, kind, resource_id, subject_type, subject_id, level, level, actor,
|
|
84
|
+
* note, then the five {@link ONE_GRANT} keys.
|
|
85
|
+
*/
|
|
86
|
+
readonly insertGrantEvent: `INSERT INTO share_events (at, action, app, kind, resource_id, subject_type, subject_id, level, previous_level, actor, note)
|
|
87
|
+
\t\t\t SELECT ?, CASE WHEN g.level IS NOT NULL AND g.level <> ? THEN 'regrade' ELSE 'grant' END,
|
|
88
|
+
\t\t\t ?, ?, ?, ?, ?, ?,
|
|
89
|
+
\t\t\t CASE WHEN g.level <> ? THEN g.level ELSE NULL END, ?, ?
|
|
90
|
+
\t\t\t FROM (SELECT 1) AS one
|
|
91
|
+
\t\t\t LEFT JOIN share_grants AS g ON g.${string}
|
|
92
|
+
\t\t\t RETURNING *`;
|
|
93
|
+
/**
|
|
94
|
+
* One `revoke` event per grant matching `where`, oldest grant first — written BEFORE the
|
|
95
|
+
* delete that follows it in the same batch. Binds at, actor, note, then `where`'s params.
|
|
96
|
+
*/
|
|
97
|
+
readonly insertRevokeEvents: (where: string) => string;
|
|
98
|
+
readonly oneGrant: "app = ? AND kind = ? AND resource_id = ? AND subject_type = ? AND subject_id = ?";
|
|
99
|
+
readonly resourceGrants: "app = ? AND kind = ? AND resource_id = ?";
|
|
100
|
+
readonly subjectGrants: "app = ? AND subject_type = ? AND subject_id = ?";
|
|
101
|
+
readonly groupGrants: "app = ? AND subject_type = 'group' AND subject_id = ?";
|
|
102
|
+
};
|
|
103
|
+
/** The trail's WHERE and its params, bound to one app — the filter both stores apply. */
|
|
104
|
+
export declare function eventsWhere(app: string, query: {
|
|
105
|
+
kind?: string;
|
|
106
|
+
id?: string;
|
|
107
|
+
subject?: {
|
|
108
|
+
type: string;
|
|
109
|
+
id: string;
|
|
110
|
+
};
|
|
111
|
+
}): {
|
|
112
|
+
where: string;
|
|
113
|
+
params: (string | number)[];
|
|
114
|
+
};
|
|
115
|
+
/** Newest first, capped: default 200, never more than 1000, never less than 1. */
|
|
116
|
+
export declare const eventLimit: (limit: number | undefined) => number;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { SHARE_EVENTS_TABLE, SHARE_GRANTS_TABLE, SHARE_GROUPS_TABLE, SHARE_GROUP_MEMBERS_TABLE } from "./schema.js";
|
|
2
|
+
export const toGrant = (row) => ({
|
|
3
|
+
resource: { app: row.app, kind: row.kind, id: row.resource_id },
|
|
4
|
+
subject: { type: row.subject_type, id: row.subject_id },
|
|
5
|
+
level: row.level,
|
|
6
|
+
grantedBy: row.granted_by,
|
|
7
|
+
grantedAt: row.granted_at,
|
|
8
|
+
note: row.note,
|
|
9
|
+
});
|
|
10
|
+
export const toEvent = (row) => ({
|
|
11
|
+
id: row.event_id,
|
|
12
|
+
at: row.at,
|
|
13
|
+
action: row.action,
|
|
14
|
+
resource: row.app === null || row.kind === null || row.resource_id === null
|
|
15
|
+
? null
|
|
16
|
+
: { app: row.app, kind: row.kind, id: row.resource_id },
|
|
17
|
+
subject: { type: row.subject_type, id: row.subject_id },
|
|
18
|
+
level: row.level,
|
|
19
|
+
previousLevel: row.previous_level,
|
|
20
|
+
actor: row.actor,
|
|
21
|
+
note: row.note,
|
|
22
|
+
});
|
|
23
|
+
/** The audit trail's columns, in the order every event insert binds them. */
|
|
24
|
+
const EVENT_COLUMNS = "(at, action, app, kind, resource_id, subject_type, subject_id, level, previous_level, actor, note)";
|
|
25
|
+
/** One grant row's WHERE — the five columns of its UNIQUE key. */
|
|
26
|
+
const ONE_GRANT = "app = ? AND kind = ? AND resource_id = ? AND subject_type = ? AND subject_id = ?";
|
|
27
|
+
const RESOURCE_GRANTS = "app = ? AND kind = ? AND resource_id = ?";
|
|
28
|
+
const SUBJECT_GRANTS = "app = ? AND subject_type = ? AND subject_id = ?";
|
|
29
|
+
const GROUP_GRANTS = "app = ? AND subject_type = 'group' AND subject_id = ?";
|
|
30
|
+
/** "This viewer, directly or through any group they are in" — binds `subjectId` twice. */
|
|
31
|
+
const VIEWER_MATCH = `( (subject_type = 'user' AND subject_id = ?)
|
|
32
|
+
OR (subject_type = 'group' AND subject_id IN
|
|
33
|
+
(SELECT group_id FROM ${SHARE_GROUP_MEMBERS_TABLE} WHERE subject_id = ?)) )`;
|
|
34
|
+
export const SHARE_SQL = {
|
|
35
|
+
/** Binds the eleven {@link EVENT_COLUMNS}. */
|
|
36
|
+
insertEvent: `INSERT INTO ${SHARE_EVENTS_TABLE}
|
|
37
|
+
${EVENT_COLUMNS}
|
|
38
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
39
|
+
selectGrant: `SELECT * FROM ${SHARE_GRANTS_TABLE} WHERE ${ONE_GRANT}`,
|
|
40
|
+
/** Binds app, kind, resource_id, subject_type, subject_id, level, granted_by, granted_at, note. */
|
|
41
|
+
upsertGrant: `INSERT INTO ${SHARE_GRANTS_TABLE}
|
|
42
|
+
(app, kind, resource_id, subject_type, subject_id, level, granted_by, granted_at, note)
|
|
43
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
44
|
+
ON CONFLICT (app, kind, resource_id, subject_type, subject_id)
|
|
45
|
+
DO UPDATE SET level = excluded.level, granted_by = excluded.granted_by,
|
|
46
|
+
granted_at = excluded.granted_at, note = excluded.note`,
|
|
47
|
+
deleteGrant: `DELETE FROM ${SHARE_GRANTS_TABLE} WHERE ${ONE_GRANT}`,
|
|
48
|
+
selectResourceGrants: `SELECT * FROM ${SHARE_GRANTS_TABLE} WHERE ${RESOURCE_GRANTS}`,
|
|
49
|
+
deleteResourceGrants: `DELETE FROM ${SHARE_GRANTS_TABLE} WHERE ${RESOURCE_GRANTS}`,
|
|
50
|
+
selectSubjectGrants: `SELECT * FROM ${SHARE_GRANTS_TABLE} WHERE ${SUBJECT_GRANTS}`,
|
|
51
|
+
deleteSubjectGrants: `DELETE FROM ${SHARE_GRANTS_TABLE} WHERE ${SUBJECT_GRANTS}`,
|
|
52
|
+
selectGroupGrants: `SELECT * FROM ${SHARE_GRANTS_TABLE} WHERE ${GROUP_GRANTS}`,
|
|
53
|
+
deleteGroupGrants: `DELETE FROM ${SHARE_GRANTS_TABLE} WHERE ${GROUP_GRANTS}`,
|
|
54
|
+
listForResource: `SELECT * FROM ${SHARE_GRANTS_TABLE}
|
|
55
|
+
WHERE ${RESOURCE_GRANTS}
|
|
56
|
+
-- Strongest first, then oldest. Spelled as a CASE rather than an
|
|
57
|
+
-- ORDER BY on level, which happens to work only because 'write'
|
|
58
|
+
-- sorts after 'read' and would silently mis-order a third level.
|
|
59
|
+
ORDER BY CASE level WHEN 'write' THEN 0 ELSE 1 END, granted_at ASC`,
|
|
60
|
+
listForSubject: `SELECT * FROM ${SHARE_GRANTS_TABLE}
|
|
61
|
+
WHERE ${SUBJECT_GRANTS}
|
|
62
|
+
ORDER BY granted_at DESC`,
|
|
63
|
+
/** Binds app, kind, resource_id, then the viewer twice. */
|
|
64
|
+
accessFor: `SELECT level FROM ${SHARE_GRANTS_TABLE}
|
|
65
|
+
WHERE ${RESOURCE_GRANTS}
|
|
66
|
+
AND ${VIEWER_MATCH}`,
|
|
67
|
+
/** Binds app, kind, each of `levelCount` levels, then the viewer twice. */
|
|
68
|
+
resourceIdsFor: (levelCount) => `SELECT DISTINCT resource_id FROM ${SHARE_GRANTS_TABLE}
|
|
69
|
+
WHERE app = ? AND kind = ? AND level IN (${Array.from({ length: levelCount }, () => "?").join(", ")})
|
|
70
|
+
AND ${VIEWER_MATCH}
|
|
71
|
+
ORDER BY resource_id`,
|
|
72
|
+
upsertGroup: `INSERT INTO ${SHARE_GROUPS_TABLE} (group_id, name, created_by, created_at, note)
|
|
73
|
+
VALUES (?, ?, ?, ?, ?)
|
|
74
|
+
ON CONFLICT (group_id) DO UPDATE SET name = excluded.name, note = excluded.note`,
|
|
75
|
+
renameGroup: `UPDATE ${SHARE_GROUPS_TABLE} SET name = ? WHERE group_id = ?`,
|
|
76
|
+
deleteGroup: `DELETE FROM ${SHARE_GROUPS_TABLE} WHERE group_id = ?`,
|
|
77
|
+
listGroups: `SELECT * FROM ${SHARE_GROUPS_TABLE} ORDER BY name`,
|
|
78
|
+
insertMember: `INSERT INTO ${SHARE_GROUP_MEMBERS_TABLE} (group_id, subject_id, added_by, added_at)
|
|
79
|
+
VALUES (?, ?, ?, ?) ON CONFLICT (group_id, subject_id) DO NOTHING`,
|
|
80
|
+
deleteMember: `DELETE FROM ${SHARE_GROUP_MEMBERS_TABLE} WHERE group_id = ? AND subject_id = ?`,
|
|
81
|
+
listMembers: `SELECT * FROM ${SHARE_GROUP_MEMBERS_TABLE} WHERE group_id = ? ORDER BY subject_id`,
|
|
82
|
+
groupIdsFor: `SELECT group_id FROM ${SHARE_GROUP_MEMBERS_TABLE} WHERE subject_id = ? ORDER BY group_id`,
|
|
83
|
+
/** The trail, newest first, under a WHERE built by {@link eventsWhere}; binds its params then the limit. */
|
|
84
|
+
events: (where) => `SELECT * FROM ${SHARE_EVENTS_TABLE} WHERE ${where} ORDER BY event_id DESC LIMIT ?`,
|
|
85
|
+
// ── The D1 store's atomic forms ────────────────────────────────────────────────────────
|
|
86
|
+
// D1 has no interactive transaction, so "read the grant, then decide what to log" cannot
|
|
87
|
+
// be two round trips without a window between them. These write the audit row FROM the
|
|
88
|
+
// grant row inside one `batch()`, so the decision and the write are one atomic step. They
|
|
89
|
+
// are listed here beside the sync forms they must agree with — `d1.spec.ts` runs both
|
|
90
|
+
// stores through one scenario and compares every table.
|
|
91
|
+
/**
|
|
92
|
+
* The grant's event, read from the grant as it stands BEFORE the upsert that follows it in
|
|
93
|
+
* the same batch: `regrade` (with the old level) when the level changes, `grant` otherwise.
|
|
94
|
+
* Binds at, level, app, kind, resource_id, subject_type, subject_id, level, level, actor,
|
|
95
|
+
* note, then the five {@link ONE_GRANT} keys.
|
|
96
|
+
*/
|
|
97
|
+
insertGrantEvent: `INSERT INTO ${SHARE_EVENTS_TABLE} ${EVENT_COLUMNS}
|
|
98
|
+
SELECT ?, CASE WHEN g.level IS NOT NULL AND g.level <> ? THEN 'regrade' ELSE 'grant' END,
|
|
99
|
+
?, ?, ?, ?, ?, ?,
|
|
100
|
+
CASE WHEN g.level <> ? THEN g.level ELSE NULL END, ?, ?
|
|
101
|
+
FROM (SELECT 1) AS one
|
|
102
|
+
LEFT JOIN ${SHARE_GRANTS_TABLE} AS g ON g.${ONE_GRANT.replaceAll(" AND ", " AND g.")}
|
|
103
|
+
RETURNING *`,
|
|
104
|
+
/**
|
|
105
|
+
* One `revoke` event per grant matching `where`, oldest grant first — written BEFORE the
|
|
106
|
+
* delete that follows it in the same batch. Binds at, actor, note, then `where`'s params.
|
|
107
|
+
*/
|
|
108
|
+
insertRevokeEvents: (where) => `INSERT INTO ${SHARE_EVENTS_TABLE} ${EVENT_COLUMNS}
|
|
109
|
+
SELECT ?, 'revoke', app, kind, resource_id, subject_type, subject_id, level, NULL, ?, ?
|
|
110
|
+
FROM ${SHARE_GRANTS_TABLE} WHERE ${where} ORDER BY grant_id
|
|
111
|
+
RETURNING *`,
|
|
112
|
+
oneGrant: ONE_GRANT,
|
|
113
|
+
resourceGrants: RESOURCE_GRANTS,
|
|
114
|
+
subjectGrants: SUBJECT_GRANTS,
|
|
115
|
+
groupGrants: GROUP_GRANTS,
|
|
116
|
+
};
|
|
117
|
+
/** The trail's WHERE and its params, bound to one app — the filter both stores apply. */
|
|
118
|
+
export function eventsWhere(app, query) {
|
|
119
|
+
const clauses = ["app IS NULL OR app = ?"];
|
|
120
|
+
const params = [app];
|
|
121
|
+
if (query.kind !== undefined) {
|
|
122
|
+
clauses.push("kind = ?");
|
|
123
|
+
params.push(query.kind);
|
|
124
|
+
}
|
|
125
|
+
if (query.id !== undefined) {
|
|
126
|
+
clauses.push("resource_id = ?");
|
|
127
|
+
params.push(query.id);
|
|
128
|
+
}
|
|
129
|
+
if (query.subject) {
|
|
130
|
+
clauses.push("subject_type = ? AND subject_id = ?");
|
|
131
|
+
params.push(query.subject.type, query.subject.id);
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
where: clauses.map((clause) => `(${clause})`).join(" AND "),
|
|
135
|
+
params,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/** Newest first, capped: default 200, never more than 1000, never less than 1. */
|
|
139
|
+
export const eventLimit = (limit) => Math.max(1, Math.min(limit ?? 200, 1000));
|
|
@@ -34,15 +34,9 @@
|
|
|
34
34
|
*/
|
|
35
35
|
import type { Database } from "bun:sqlite";
|
|
36
36
|
import { type ShareAccess, type ShareEvent, type ShareGrant, type ShareGroup, type ShareGroupMember, type ShareLevel, type ShareSubject, type ShareViewer } from "cursedbelt-core/sharing/model";
|
|
37
|
-
|
|
38
|
-
export
|
|
39
|
-
export
|
|
40
|
-
/** A resource within the store's own app — the `app` half is supplied by the
|
|
41
|
-
* store, so a caller cannot mistakenly write a grant into another app's rows. */
|
|
42
|
-
export interface ShareTarget {
|
|
43
|
-
kind: string;
|
|
44
|
-
id: string;
|
|
45
|
-
}
|
|
37
|
+
import type { GrantInput, RevokeInput, ShareEventQuery, ShareTarget } from "./contract.js";
|
|
38
|
+
export { group, user } from "./contract.js";
|
|
39
|
+
export type { GrantInput, RevokeInput, ShareEventQuery, ShareTarget } from "./contract.js";
|
|
46
40
|
export interface ShareStoreOptions {
|
|
47
41
|
db: Database;
|
|
48
42
|
/** The satellite this store speaks for. Fixed at construction so `app` cannot
|
|
@@ -66,25 +60,6 @@ export interface ShareStoreOptions {
|
|
|
66
60
|
/** Injectable clock, for tests that assert ordering. ISO-8601 strings. */
|
|
67
61
|
now?: () => string;
|
|
68
62
|
}
|
|
69
|
-
export interface GrantInput extends ShareTarget {
|
|
70
|
-
subject: ShareSubject;
|
|
71
|
-
level: ShareLevel;
|
|
72
|
-
grantedBy: string;
|
|
73
|
-
note?: string;
|
|
74
|
-
}
|
|
75
|
-
export interface RevokeInput extends ShareTarget {
|
|
76
|
-
subject: ShareSubject;
|
|
77
|
-
revokedBy: string;
|
|
78
|
-
note?: string;
|
|
79
|
-
}
|
|
80
|
-
export interface ShareEventQuery {
|
|
81
|
-
kind?: string;
|
|
82
|
-
id?: string;
|
|
83
|
-
subject?: ShareSubject;
|
|
84
|
-
/** Newest first, capped. Default 200 — a panel shows a page, and an unbounded
|
|
85
|
-
* audit read on a busy resource is the query that times out in production. */
|
|
86
|
-
limit?: number;
|
|
87
|
-
}
|
|
88
63
|
export type ShareStore = ReturnType<typeof createShareStore>;
|
|
89
64
|
/**
|
|
90
65
|
* Open (and migrate) the sharing tables in this database, returning the store.
|
|
@@ -1,31 +1,11 @@
|
|
|
1
1
|
import { levelsMeeting, normalizeSubjectId, strongerAccess, } from "cursedbelt-core/sharing/model";
|
|
2
2
|
import { runMigrations } from "../migration/runner.js";
|
|
3
|
-
import {
|
|
3
|
+
import { SHARE_MIGRATIONS } from "./migrations.js";
|
|
4
4
|
import { sharedResourceSql } from "./sql.js";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
resource: { app: row.app, kind: row.kind, id: row.resource_id },
|
|
10
|
-
subject: { type: row.subject_type, id: row.subject_id },
|
|
11
|
-
level: row.level,
|
|
12
|
-
grantedBy: row.granted_by,
|
|
13
|
-
grantedAt: row.granted_at,
|
|
14
|
-
note: row.note,
|
|
15
|
-
});
|
|
16
|
-
const toEvent = (row) => ({
|
|
17
|
-
id: row.event_id,
|
|
18
|
-
at: row.at,
|
|
19
|
-
action: row.action,
|
|
20
|
-
resource: row.app === null || row.kind === null || row.resource_id === null
|
|
21
|
-
? null
|
|
22
|
-
: { app: row.app, kind: row.kind, id: row.resource_id },
|
|
23
|
-
subject: { type: row.subject_type, id: row.subject_id },
|
|
24
|
-
level: row.level,
|
|
25
|
-
previousLevel: row.previous_level,
|
|
26
|
-
actor: row.actor,
|
|
27
|
-
note: row.note,
|
|
28
|
-
});
|
|
5
|
+
import { eventLimit, eventsWhere, SHARE_SQL, toEvent, toGrant, } from "./statements.js";
|
|
6
|
+
// The contract has no driver in it, so `./d1.ts` (which runs on `workerd`) shares it without
|
|
7
|
+
// reaching this file. Re-exported so `cursedbelt-server/sharing` is unchanged.
|
|
8
|
+
export { group, user } from "./contract.js";
|
|
29
9
|
/**
|
|
30
10
|
* Open (and migrate) the sharing tables in this database, returning the store.
|
|
31
11
|
*
|
|
@@ -54,9 +34,7 @@ export function createShareStore(options) {
|
|
|
54
34
|
}
|
|
55
35
|
};
|
|
56
36
|
const recordEvent = (input) => {
|
|
57
|
-
db.query(
|
|
58
|
-
(at, action, app, kind, resource_id, subject_type, subject_id, level, previous_level, actor, note)
|
|
59
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(input.at, input.action, input.resource?.app ?? null, input.resource?.kind ?? null, input.resource?.id ?? null, input.subject.type, input.subject.id, input.level, input.previousLevel ?? null, input.actor, input.note ?? "");
|
|
37
|
+
db.query(SHARE_SQL.insertEvent).run(input.at, input.action, input.resource?.app ?? null, input.resource?.kind ?? null, input.resource?.id ?? null, input.subject.type, input.subject.id, input.level, input.previousLevel ?? null, input.actor, input.note ?? "");
|
|
60
38
|
const id = db.query("SELECT last_insert_rowid() AS id").get().id;
|
|
61
39
|
return {
|
|
62
40
|
id,
|
|
@@ -76,8 +54,7 @@ export function createShareStore(options) {
|
|
|
76
54
|
});
|
|
77
55
|
const readGrant = (target, subject) => {
|
|
78
56
|
const row = db
|
|
79
|
-
.query(
|
|
80
|
-
WHERE app = ? AND kind = ? AND resource_id = ? AND subject_type = ? AND subject_id = ?`)
|
|
57
|
+
.query(SHARE_SQL.selectGrant)
|
|
81
58
|
.get(app, target.kind, target.id, subject.type, subject.id);
|
|
82
59
|
return row ? toGrant(row) : null;
|
|
83
60
|
};
|
|
@@ -102,12 +79,7 @@ export function createShareStore(options) {
|
|
|
102
79
|
const events = [];
|
|
103
80
|
const grant = db.transaction(() => {
|
|
104
81
|
const existing = readGrant(input, subject);
|
|
105
|
-
db.query(
|
|
106
|
-
(app, kind, resource_id, subject_type, subject_id, level, granted_by, granted_at, note)
|
|
107
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
108
|
-
ON CONFLICT (app, kind, resource_id, subject_type, subject_id)
|
|
109
|
-
DO UPDATE SET level = excluded.level, granted_by = excluded.granted_by,
|
|
110
|
-
granted_at = excluded.granted_at, note = excluded.note`).run(app, input.kind, input.id, subject.type, subject.id, input.level, input.grantedBy, at, input.note ?? "");
|
|
82
|
+
db.query(SHARE_SQL.upsertGrant).run(app, input.kind, input.id, subject.type, subject.id, input.level, input.grantedBy, at, input.note ?? "");
|
|
111
83
|
const resource = { app, kind: input.kind, id: input.id };
|
|
112
84
|
events.push(recordEvent({
|
|
113
85
|
action: existing && existing.level !== input.level ? "regrade" : "grant",
|
|
@@ -142,8 +114,7 @@ export function createShareStore(options) {
|
|
|
142
114
|
const existing = readGrant(input, subject);
|
|
143
115
|
if (!existing)
|
|
144
116
|
return null;
|
|
145
|
-
db.query(
|
|
146
|
-
WHERE app = ? AND kind = ? AND resource_id = ? AND subject_type = ? AND subject_id = ?`).run(app, input.kind, input.id, subject.type, subject.id);
|
|
117
|
+
db.query(SHARE_SQL.deleteGrant).run(app, input.kind, input.id, subject.type, subject.id);
|
|
147
118
|
events.push(recordEvent({
|
|
148
119
|
action: "revoke",
|
|
149
120
|
resource: { app, kind: input.kind, id: input.id },
|
|
@@ -172,11 +143,11 @@ export function createShareStore(options) {
|
|
|
172
143
|
const events = [];
|
|
173
144
|
const count = db.transaction(() => {
|
|
174
145
|
const rows = db
|
|
175
|
-
.query(
|
|
146
|
+
.query(SHARE_SQL.selectResourceGrants)
|
|
176
147
|
.all(app, target.kind, target.id);
|
|
177
148
|
if (rows.length === 0)
|
|
178
149
|
return 0;
|
|
179
|
-
db.query(
|
|
150
|
+
db.query(SHARE_SQL.deleteResourceGrants).run(app, target.kind, target.id);
|
|
180
151
|
for (const row of rows) {
|
|
181
152
|
events.push(recordEvent({
|
|
182
153
|
action: "revoke",
|
|
@@ -201,11 +172,11 @@ export function createShareStore(options) {
|
|
|
201
172
|
const events = [];
|
|
202
173
|
const count = db.transaction(() => {
|
|
203
174
|
const rows = db
|
|
204
|
-
.query(
|
|
175
|
+
.query(SHARE_SQL.selectSubjectGrants)
|
|
205
176
|
.all(app, normal.type, normal.id);
|
|
206
177
|
if (rows.length === 0)
|
|
207
178
|
return 0;
|
|
208
|
-
db.query(
|
|
179
|
+
db.query(SHARE_SQL.deleteSubjectGrants).run(app, normal.type, normal.id);
|
|
209
180
|
for (const row of rows) {
|
|
210
181
|
events.push(recordEvent({
|
|
211
182
|
action: "revoke",
|
|
@@ -224,25 +195,14 @@ export function createShareStore(options) {
|
|
|
224
195
|
},
|
|
225
196
|
/** Who can see this record, strongest first then oldest — the panel's read. */
|
|
226
197
|
listForResource(target) {
|
|
227
|
-
const rows = db
|
|
228
|
-
.query(`SELECT * FROM ${SHARE_GRANTS_TABLE}
|
|
229
|
-
WHERE app = ? AND kind = ? AND resource_id = ?
|
|
230
|
-
-- Strongest first, then oldest. Spelled as a CASE rather than an
|
|
231
|
-
-- ORDER BY on level, which happens to work only because 'write'
|
|
232
|
-
-- sorts after 'read' and would silently mis-order a third level.
|
|
233
|
-
ORDER BY CASE level WHEN 'write' THEN 0 ELSE 1 END, granted_at ASC`)
|
|
234
|
-
.all(app, target.kind, target.id);
|
|
198
|
+
const rows = db.query(SHARE_SQL.listForResource).all(app, target.kind, target.id);
|
|
235
199
|
return rows.map(toGrant);
|
|
236
200
|
},
|
|
237
201
|
/** Everything shared with one subject — "shared with you". Group grants are
|
|
238
202
|
* NOT expanded here; use {@link resourceIdsFor} for the resolved answer. */
|
|
239
203
|
listForSubject(subject) {
|
|
240
204
|
const normal = normalized(subject);
|
|
241
|
-
const rows = db
|
|
242
|
-
.query(`SELECT * FROM ${SHARE_GRANTS_TABLE}
|
|
243
|
-
WHERE app = ? AND subject_type = ? AND subject_id = ?
|
|
244
|
-
ORDER BY granted_at DESC`)
|
|
245
|
-
.all(app, normal.type, normal.id);
|
|
205
|
+
const rows = db.query(SHARE_SQL.listForSubject).all(app, normal.type, normal.id);
|
|
246
206
|
return rows.map(toGrant);
|
|
247
207
|
},
|
|
248
208
|
/**
|
|
@@ -257,11 +217,7 @@ export function createShareStore(options) {
|
|
|
257
217
|
if (!subjectId)
|
|
258
218
|
return "none";
|
|
259
219
|
const rows = db
|
|
260
|
-
.query(
|
|
261
|
-
WHERE app = ? AND kind = ? AND resource_id = ?
|
|
262
|
-
AND ( (subject_type = 'user' AND subject_id = ?)
|
|
263
|
-
OR (subject_type = 'group' AND subject_id IN
|
|
264
|
-
(SELECT group_id FROM ${SHARE_GROUP_MEMBERS_TABLE} WHERE subject_id = ?)) )`)
|
|
220
|
+
.query(SHARE_SQL.accessFor)
|
|
265
221
|
.all(app, target.kind, target.id, subjectId, subjectId);
|
|
266
222
|
let access = "none";
|
|
267
223
|
for (const row of rows)
|
|
@@ -276,12 +232,7 @@ export function createShareStore(options) {
|
|
|
276
232
|
return [];
|
|
277
233
|
const levels = levelsMeeting(atLeast);
|
|
278
234
|
const rows = db
|
|
279
|
-
.query(
|
|
280
|
-
WHERE app = ? AND kind = ? AND level IN (${levels.map(() => "?").join(", ")})
|
|
281
|
-
AND ( (subject_type = 'user' AND subject_id = ?)
|
|
282
|
-
OR (subject_type = 'group' AND subject_id IN
|
|
283
|
-
(SELECT group_id FROM ${SHARE_GROUP_MEMBERS_TABLE} WHERE subject_id = ?)) )
|
|
284
|
-
ORDER BY resource_id`)
|
|
235
|
+
.query(SHARE_SQL.resourceIdsFor(levels.length))
|
|
285
236
|
.all(app, kind, ...levels, subjectId, subjectId);
|
|
286
237
|
return rows.map((row) => row.resource_id);
|
|
287
238
|
},
|
|
@@ -298,9 +249,7 @@ export function createShareStore(options) {
|
|
|
298
249
|
const at = clock();
|
|
299
250
|
const events = [];
|
|
300
251
|
const created = db.transaction(() => {
|
|
301
|
-
db.query(
|
|
302
|
-
VALUES (?, ?, ?, ?, ?)
|
|
303
|
-
ON CONFLICT (group_id) DO UPDATE SET name = excluded.name, note = excluded.note`).run(id, input.name, input.createdBy, at, input.note ?? "");
|
|
252
|
+
db.query(SHARE_SQL.upsertGroup).run(id, input.name, input.createdBy, at, input.note ?? "");
|
|
304
253
|
events.push(recordEvent({
|
|
305
254
|
action: "group.create",
|
|
306
255
|
resource: null,
|
|
@@ -326,7 +275,7 @@ export function createShareStore(options) {
|
|
|
326
275
|
const at = clock();
|
|
327
276
|
const events = [];
|
|
328
277
|
db.transaction(() => {
|
|
329
|
-
db.query(
|
|
278
|
+
db.query(SHARE_SQL.renameGroup).run(name, groupId);
|
|
330
279
|
events.push(recordEvent({
|
|
331
280
|
action: "group.rename",
|
|
332
281
|
resource: null,
|
|
@@ -353,10 +302,8 @@ export function createShareStore(options) {
|
|
|
353
302
|
const at = clock();
|
|
354
303
|
const events = [];
|
|
355
304
|
db.transaction(() => {
|
|
356
|
-
const grants = db
|
|
357
|
-
|
|
358
|
-
.all(app, groupId);
|
|
359
|
-
db.query(`DELETE FROM ${SHARE_GRANTS_TABLE} WHERE app = ? AND subject_type = 'group' AND subject_id = ?`).run(app, groupId);
|
|
305
|
+
const grants = db.query(SHARE_SQL.selectGroupGrants).all(app, groupId);
|
|
306
|
+
db.query(SHARE_SQL.deleteGroupGrants).run(app, groupId);
|
|
360
307
|
for (const row of grants) {
|
|
361
308
|
events.push(recordEvent({
|
|
362
309
|
action: "revoke",
|
|
@@ -368,7 +315,7 @@ export function createShareStore(options) {
|
|
|
368
315
|
at,
|
|
369
316
|
}));
|
|
370
317
|
}
|
|
371
|
-
db.query(
|
|
318
|
+
db.query(SHARE_SQL.deleteGroup).run(groupId);
|
|
372
319
|
events.push(recordEvent({
|
|
373
320
|
action: "group.delete",
|
|
374
321
|
resource: null,
|
|
@@ -382,9 +329,7 @@ export function createShareStore(options) {
|
|
|
382
329
|
emit(events);
|
|
383
330
|
},
|
|
384
331
|
listGroups() {
|
|
385
|
-
const rows = db
|
|
386
|
-
.query(`SELECT * FROM ${SHARE_GROUPS_TABLE} ORDER BY name`)
|
|
387
|
-
.all();
|
|
332
|
+
const rows = db.query(SHARE_SQL.listGroups).all();
|
|
388
333
|
return rows.map((row) => ({
|
|
389
334
|
id: row.group_id,
|
|
390
335
|
name: row.name,
|
|
@@ -401,8 +346,7 @@ export function createShareStore(options) {
|
|
|
401
346
|
const at = clock();
|
|
402
347
|
const events = [];
|
|
403
348
|
db.transaction(() => {
|
|
404
|
-
db.query(
|
|
405
|
-
VALUES (?, ?, ?, ?) ON CONFLICT (group_id, subject_id) DO NOTHING`).run(id, member, actor, at);
|
|
349
|
+
db.query(SHARE_SQL.insertMember).run(id, member, actor, at);
|
|
406
350
|
events.push(recordEvent({
|
|
407
351
|
action: "group.join",
|
|
408
352
|
resource: null,
|
|
@@ -421,7 +365,7 @@ export function createShareStore(options) {
|
|
|
421
365
|
const at = clock();
|
|
422
366
|
const events = [];
|
|
423
367
|
db.transaction(() => {
|
|
424
|
-
db.query(
|
|
368
|
+
db.query(SHARE_SQL.deleteMember).run(id, member);
|
|
425
369
|
events.push(recordEvent({
|
|
426
370
|
action: "group.leave",
|
|
427
371
|
resource: null,
|
|
@@ -436,7 +380,7 @@ export function createShareStore(options) {
|
|
|
436
380
|
},
|
|
437
381
|
listGroupMembers(groupId) {
|
|
438
382
|
const rows = db
|
|
439
|
-
.query(
|
|
383
|
+
.query(SHARE_SQL.listMembers)
|
|
440
384
|
.all(normalizeSubjectId("group", groupId));
|
|
441
385
|
return rows.map((row) => ({
|
|
442
386
|
groupId: row.group_id,
|
|
@@ -450,34 +394,17 @@ export function createShareStore(options) {
|
|
|
450
394
|
const member = normalizeSubjectId("user", subjectId);
|
|
451
395
|
if (!member)
|
|
452
396
|
return [];
|
|
453
|
-
const rows = db
|
|
454
|
-
.query(`SELECT group_id FROM ${SHARE_GROUP_MEMBERS_TABLE} WHERE subject_id = ? ORDER BY group_id`)
|
|
455
|
-
.all(member);
|
|
397
|
+
const rows = db.query(SHARE_SQL.groupIdsFor).all(member);
|
|
456
398
|
return rows.map((row) => row.group_id);
|
|
457
399
|
},
|
|
458
400
|
// ── The audit trail ──────────────────────────────────────────────────────
|
|
459
401
|
/** The trail, newest first. Filter by resource, by subject, or neither. */
|
|
460
402
|
events(query = {}) {
|
|
461
|
-
const
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
}
|
|
467
|
-
if (query.id !== undefined) {
|
|
468
|
-
clauses.push("resource_id = ?");
|
|
469
|
-
params.push(query.id);
|
|
470
|
-
}
|
|
471
|
-
if (query.subject) {
|
|
472
|
-
const normal = normalized(query.subject);
|
|
473
|
-
clauses.push("subject_type = ? AND subject_id = ?");
|
|
474
|
-
params.push(normal.type, normal.id);
|
|
475
|
-
}
|
|
476
|
-
const where = clauses.map((clause) => `(${clause})`).join(" AND ");
|
|
477
|
-
const limit = Math.max(1, Math.min(query.limit ?? 200, 1000));
|
|
478
|
-
const rows = db
|
|
479
|
-
.query(`SELECT * FROM ${SHARE_EVENTS_TABLE} WHERE ${where} ORDER BY event_id DESC LIMIT ?`)
|
|
480
|
-
.all(...params, limit);
|
|
403
|
+
const { where, params } = eventsWhere(app, {
|
|
404
|
+
...query,
|
|
405
|
+
subject: query.subject ? normalized(query.subject) : undefined,
|
|
406
|
+
});
|
|
407
|
+
const rows = db.query(SHARE_SQL.events(where)).all(...params, eventLimit(query.limit));
|
|
481
408
|
return rows.map(toEvent);
|
|
482
409
|
},
|
|
483
410
|
};
|
package/docs/activity.md
CHANGED
|
@@ -40,4 +40,11 @@ a merge across apps stays labelled.
|
|
|
40
40
|
`describeActivityEvent` / `describeActivityResource` produce the sentence a reader sees. An app
|
|
41
41
|
that writes its own sentence for the same event creates a second wording that drifts.
|
|
42
42
|
|
|
43
|
+
## 5. On a Worker, the same stream lives in D1
|
|
44
|
+
|
|
45
|
+
`cursedbelt-server/activity/d1` (since 4.28.0, task 393) is `createD1ActivityLog` — the same
|
|
46
|
+
`record`, `query`, `count`, `facets` and `sources`, each async, over the same `activity_events`
|
|
47
|
+
table and the same filter semantics (one shared `query.ts`). `shareEventsSource` is re-exported
|
|
48
|
+
there. It runs no DDL: `ACTIVITY_DDL` goes into the app's `db/schema.sql`. It is Worker-safe.
|
|
49
|
+
|
|
43
50
|
Cite this page from an app as `cursedbelt-server/docs/activity.md`.
|
package/docs/engagement.md
CHANGED
|
@@ -44,4 +44,10 @@ manifest parses (`apps/roms/routes.manifest.test.ts` is the pattern).
|
|
|
44
44
|
excluded app, `SATELLITE_ENGAGEMENT=0` or an unresolvable data directory each turn recording OFF
|
|
45
45
|
and say why, and the app still boots.
|
|
46
46
|
|
|
47
|
+
**On a Worker**, the same three tables live in the app's own D1 instead:
|
|
48
|
+
`cursedbelt-server/engagement/d1` (since 4.27.0, task 2096) exports `ENGAGEMENT_DDL` for the
|
|
49
|
+
app's `db/schema.sql`, `createD1EngagementRecorder` to mount inside the gate, and
|
|
50
|
+
`placesFromManifest` for the bundled `routes.manifest.json`. It is Worker-safe and uses the same
|
|
51
|
+
`policy.ts`, so there is one privacy rule whichever host records. station reads either shape.
|
|
52
|
+
|
|
47
53
|
Cite this page from an app as `cursedbelt-server/docs/engagement.md`.
|
package/docs/notifications.md
CHANGED
|
@@ -40,4 +40,13 @@ It is not filtered by the reader's scope at read time and cannot be. Put what is
|
|
|
40
40
|
recipient in the title and body, and let the LINK carry them to the thing, where every normal
|
|
41
41
|
read filter applies (`family`'s share notification does not name the relative for this reason).
|
|
42
42
|
|
|
43
|
+
## 5. On a Worker, the inbox lives in D1 and there is no live stream
|
|
44
|
+
|
|
45
|
+
`cursedbelt-server/notifications/d1` (since 4.28.0, task 393) is `createD1NotificationService` —
|
|
46
|
+
the same `{ center, store, mount, close }`, with cwip's center over a D1 implementation of its
|
|
47
|
+
`NotificationStore` and the same table (`NOTIFICATIONS_DDL` / `notificationsDdl(table)` for the
|
|
48
|
+
app's `db/schema.sql`; nothing runs DDL at request time). `hub` is always `null` and
|
|
49
|
+
`{base}/stream` is not mounted: an isolate cannot share open streams, so polling (rule 1's
|
|
50
|
+
backstop) is the delivery. It is Worker-safe.
|
|
51
|
+
|
|
43
52
|
Cite this page from an app as `cursedbelt-server/docs/notifications.md`.
|