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
|
@@ -32,97 +32,24 @@
|
|
|
32
32
|
*/
|
|
33
33
|
import type { Database } from "bun:sqlite";
|
|
34
34
|
import type { Migration } from "../migration/types.js";
|
|
35
|
+
import { SHARE_DDL } from "./schema.js";
|
|
35
36
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
export
|
|
37
|
+
// The names and the DDL live in `./schema.ts`, driver-free, so a D1 app builds its schema file
|
|
38
|
+
// from the SAME statements this migration runs (`cursedbelt-server/sharing/d1` re-exports
|
|
39
|
+
// them). Re-exported here so every existing import of this module keeps working.
|
|
40
|
+
export {
|
|
41
|
+
SHARE_DDL,
|
|
42
|
+
SHARE_EVENTS_TABLE,
|
|
43
|
+
SHARE_GRANTS_TABLE,
|
|
44
|
+
SHARE_GROUPS_TABLE,
|
|
45
|
+
SHARE_GROUP_MEMBERS_TABLE,
|
|
46
|
+
} from "./schema.js";
|
|
40
47
|
|
|
41
48
|
export const SHARE_MIGRATIONS: readonly Migration[] = [
|
|
42
49
|
{
|
|
43
50
|
name: "0001_share_grants",
|
|
44
51
|
up(db: Database): void {
|
|
45
|
-
db.run(
|
|
46
|
-
CREATE TABLE IF NOT EXISTS ${SHARE_GRANTS_TABLE} (
|
|
47
|
-
grant_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
48
|
-
app TEXT NOT NULL,
|
|
49
|
-
kind TEXT NOT NULL,
|
|
50
|
-
resource_id TEXT NOT NULL,
|
|
51
|
-
subject_type TEXT NOT NULL CHECK (subject_type IN ('user', 'group')),
|
|
52
|
-
subject_id TEXT NOT NULL,
|
|
53
|
-
level TEXT NOT NULL CHECK (level IN ('read', 'write')),
|
|
54
|
-
granted_by TEXT NOT NULL,
|
|
55
|
-
granted_at TEXT NOT NULL,
|
|
56
|
-
note TEXT NOT NULL DEFAULT '',
|
|
57
|
-
-- One grant per subject per resource. This is what makes raising
|
|
58
|
-
-- read to write an UPDATE the audit trail can describe, instead of
|
|
59
|
-
-- two rows where revoking one leaves access in place.
|
|
60
|
-
UNIQUE (app, kind, resource_id, subject_type, subject_id)
|
|
61
|
-
)
|
|
62
|
-
`);
|
|
63
|
-
// The hot read is the resolver's: "does this subject hold a grant on this
|
|
64
|
-
// row" — correlated per row of a list query, so it must be an index hit.
|
|
65
|
-
db.run(
|
|
66
|
-
`CREATE INDEX IF NOT EXISTS idx_share_grants_subject
|
|
67
|
-
ON ${SHARE_GRANTS_TABLE} (subject_type, subject_id, app, kind, resource_id)`,
|
|
68
|
-
);
|
|
69
|
-
// …and the panel's: "who can see this record".
|
|
70
|
-
db.run(
|
|
71
|
-
`CREATE INDEX IF NOT EXISTS idx_share_grants_resource
|
|
72
|
-
ON ${SHARE_GRANTS_TABLE} (app, kind, resource_id)`,
|
|
73
|
-
);
|
|
74
|
-
|
|
75
|
-
db.run(`
|
|
76
|
-
CREATE TABLE IF NOT EXISTS ${SHARE_GROUPS_TABLE} (
|
|
77
|
-
group_id TEXT PRIMARY KEY,
|
|
78
|
-
name TEXT NOT NULL,
|
|
79
|
-
created_by TEXT NOT NULL,
|
|
80
|
-
created_at TEXT NOT NULL,
|
|
81
|
-
note TEXT NOT NULL DEFAULT ''
|
|
82
|
-
)
|
|
83
|
-
`);
|
|
84
|
-
|
|
85
|
-
db.run(`
|
|
86
|
-
CREATE TABLE IF NOT EXISTS ${SHARE_GROUP_MEMBERS_TABLE} (
|
|
87
|
-
group_id TEXT NOT NULL REFERENCES ${SHARE_GROUPS_TABLE} (group_id) ON DELETE CASCADE,
|
|
88
|
-
subject_id TEXT NOT NULL,
|
|
89
|
-
added_by TEXT NOT NULL,
|
|
90
|
-
added_at TEXT NOT NULL,
|
|
91
|
-
PRIMARY KEY (group_id, subject_id)
|
|
92
|
-
)
|
|
93
|
-
`);
|
|
94
|
-
// "Which groups is this person in" — read by every shared-row query.
|
|
95
|
-
db.run(
|
|
96
|
-
`CREATE INDEX IF NOT EXISTS idx_share_group_members_subject
|
|
97
|
-
ON ${SHARE_GROUP_MEMBERS_TABLE} (subject_id)`,
|
|
98
|
-
);
|
|
99
|
-
|
|
100
|
-
db.run(`
|
|
101
|
-
CREATE TABLE IF NOT EXISTS ${SHARE_EVENTS_TABLE} (
|
|
102
|
-
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
103
|
-
at TEXT NOT NULL,
|
|
104
|
-
action TEXT NOT NULL,
|
|
105
|
-
app TEXT,
|
|
106
|
-
kind TEXT,
|
|
107
|
-
resource_id TEXT,
|
|
108
|
-
subject_type TEXT NOT NULL CHECK (subject_type IN ('user', 'group')),
|
|
109
|
-
subject_id TEXT NOT NULL,
|
|
110
|
-
level TEXT,
|
|
111
|
-
previous_level TEXT,
|
|
112
|
-
actor TEXT NOT NULL,
|
|
113
|
-
note TEXT NOT NULL DEFAULT ''
|
|
114
|
-
)
|
|
115
|
-
`);
|
|
116
|
-
// "Everything that ever happened to this record", newest first.
|
|
117
|
-
db.run(
|
|
118
|
-
`CREATE INDEX IF NOT EXISTS idx_share_events_resource
|
|
119
|
-
ON ${SHARE_EVENTS_TABLE} (app, kind, resource_id, event_id DESC)`,
|
|
120
|
-
);
|
|
121
|
-
// "Everything this person was ever given" — the offboarding question.
|
|
122
|
-
db.run(
|
|
123
|
-
`CREATE INDEX IF NOT EXISTS idx_share_events_subject
|
|
124
|
-
ON ${SHARE_EVENTS_TABLE} (subject_id, event_id DESC)`,
|
|
125
|
-
);
|
|
52
|
+
for (const statement of SHARE_DDL) db.run(statement);
|
|
126
53
|
},
|
|
127
54
|
},
|
|
128
55
|
];
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
*/
|
|
45
45
|
import type { Context, Hono } from 'hono';
|
|
46
46
|
import type { ShareEvent, ShareGrant, ShareLevel } from 'cursedbelt-core/sharing/model';
|
|
47
|
-
import type {
|
|
47
|
+
import type { AwaitableShareStore } from './contract.js';
|
|
48
48
|
|
|
49
49
|
/** The `group:` prefix that lets a group subject travel in a path segment. Exported so a
|
|
50
50
|
* client and a route cannot disagree about it. */
|
|
@@ -77,8 +77,12 @@ export interface ShareRouterOptions<V extends ShareRouterViewer> {
|
|
|
77
77
|
base: string;
|
|
78
78
|
/** The `kind` half of the store's `(app, kind, id)` triple. */
|
|
79
79
|
kind: string;
|
|
80
|
-
/**
|
|
81
|
-
|
|
80
|
+
/**
|
|
81
|
+
* The store — the sync `ShareStore` or the D1 `AsyncShareStore` (`./sharing/d1`); every
|
|
82
|
+
* call is awaited, and awaiting a sync store's plain value changes nothing. A function
|
|
83
|
+
* when it is per-request (a multi-tenant app, or a Worker whose D1 handle is per request).
|
|
84
|
+
*/
|
|
85
|
+
shares: AwaitableShareStore | ((c: Context) => AwaitableShareStore);
|
|
82
86
|
/** Who is asking. Returning null refuses every route with 401. */
|
|
83
87
|
viewerOf: (c: Context) => V | null;
|
|
84
88
|
/**
|
|
@@ -89,7 +93,11 @@ export interface ShareRouterOptions<V extends ShareRouterViewer> {
|
|
|
89
93
|
mustOwn: (c: Context, id: string, viewer: V) => Response | null | Promise<Response | null>;
|
|
90
94
|
/** Extra fields folded into the READ payload beside `{grants, events}` — family's
|
|
91
95
|
* `labels`, life's `groups`. */
|
|
92
|
-
extra?: (
|
|
96
|
+
extra?: (
|
|
97
|
+
c: Context,
|
|
98
|
+
id: string,
|
|
99
|
+
viewer: V,
|
|
100
|
+
) => Record<string, unknown> | Promise<Record<string, unknown>>;
|
|
93
101
|
/** How many audit lines the read carries. Default 50. */
|
|
94
102
|
trailLimit?: number;
|
|
95
103
|
}
|
|
@@ -125,13 +133,13 @@ export function mountShareRoutes<V extends ShareRouterViewer>(
|
|
|
125
133
|
options: ShareRouterOptions<V>,
|
|
126
134
|
): void {
|
|
127
135
|
const { base, kind, viewerOf, mustOwn, extra, trailLimit = 50 } = options;
|
|
128
|
-
const storeOf = (c: Context):
|
|
136
|
+
const storeOf = (c: Context): AwaitableShareStore =>
|
|
129
137
|
typeof options.shares === 'function' ? options.shares(c) : options.shares;
|
|
130
138
|
|
|
131
139
|
/** The three-step preamble every route shares: identity, id, ownership. */
|
|
132
140
|
async function enter(
|
|
133
141
|
c: Context,
|
|
134
|
-
): Promise<{ refused: Response } | { id: string; viewer: V; shares:
|
|
142
|
+
): Promise<{ refused: Response } | { id: string; viewer: V; shares: AwaitableShareStore }> {
|
|
135
143
|
const viewer = viewerOf(c);
|
|
136
144
|
if (!viewer) return { refused: c.json({ error: 'sign in first' }, 401) };
|
|
137
145
|
const id = c.req.param('id') ?? '';
|
|
@@ -147,9 +155,11 @@ export function mountShareRoutes<V extends ShareRouterViewer>(
|
|
|
147
155
|
const payload: ShareRoutesPayload & Record<string, unknown> = {
|
|
148
156
|
// The store's own rows, straight through — the sharing panel consumes exactly
|
|
149
157
|
// these, and a DTO in the middle is a place for a field to go missing.
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
158
|
+
// Awaited in this order — grants, trail, extra — which is the order the sync store
|
|
159
|
+
// was always read in.
|
|
160
|
+
grants: await shares.listForResource({ kind, id }),
|
|
161
|
+
events: await shares.events({ kind, id, limit: trailLimit }),
|
|
162
|
+
...(extra ? await extra(c, id, viewer) : {}),
|
|
153
163
|
};
|
|
154
164
|
return c.json(payload);
|
|
155
165
|
});
|
|
@@ -171,7 +181,7 @@ export function mountShareRoutes<V extends ShareRouterViewer>(
|
|
|
171
181
|
if (body.level !== undefined && !isLevel(body.level)) {
|
|
172
182
|
return c.json({ error: "level must be 'read' or 'write'" }, 400);
|
|
173
183
|
}
|
|
174
|
-
const grant = shares.grant({
|
|
184
|
+
const grant = await shares.grant({
|
|
175
185
|
kind,
|
|
176
186
|
id,
|
|
177
187
|
subject: { type: body.subject?.type === 'group' ? 'group' : 'user', id: subjectId },
|
|
@@ -188,7 +198,7 @@ export function mountShareRoutes<V extends ShareRouterViewer>(
|
|
|
188
198
|
const { id, viewer, shares } = step;
|
|
189
199
|
const raw = c.req.param('subject') ?? '';
|
|
190
200
|
if (!raw.trim()) return c.json({ error: 'which share should be removed?' }, 400);
|
|
191
|
-
shares.revoke({ kind, id, subject: parseSubjectParam(raw), revokedBy: viewer.email });
|
|
201
|
+
await shares.revoke({ kind, id, subject: parseSubjectParam(raw), revokedBy: viewer.email });
|
|
192
202
|
return c.json({ ok: true });
|
|
193
203
|
});
|
|
194
204
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sharing tables' names and DDL, with no driver attached — the ONE copy both the
|
|
3
|
+
* `bun:sqlite` migration (`./migrations.ts`) and a D1 app's `db/schema.sql` are built from.
|
|
4
|
+
*
|
|
5
|
+
* It is import-free on purpose: `./d1.ts` runs on `workerd`, and a Worker app generates its
|
|
6
|
+
* schema file from these statements rather than running DDL at request time. The migration
|
|
7
|
+
* runs exactly this list, so the two can never describe different tables. Why each table and
|
|
8
|
+
* each index exists is `./migrations.ts`'s header.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const SHARE_GRANTS_TABLE = "share_grants";
|
|
12
|
+
export const SHARE_GROUPS_TABLE = "share_groups";
|
|
13
|
+
export const SHARE_GROUP_MEMBERS_TABLE = "share_group_members";
|
|
14
|
+
export const SHARE_EVENTS_TABLE = "share_events";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Every statement of the `0001_share_grants` migration, in order. Idempotent (`IF NOT
|
|
18
|
+
* EXISTS` throughout), so a schema file built from it can be applied to a database that
|
|
19
|
+
* already has the tables.
|
|
20
|
+
*/
|
|
21
|
+
export const SHARE_DDL: readonly string[] = [
|
|
22
|
+
`
|
|
23
|
+
CREATE TABLE IF NOT EXISTS ${SHARE_GRANTS_TABLE} (
|
|
24
|
+
grant_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
25
|
+
app TEXT NOT NULL,
|
|
26
|
+
kind TEXT NOT NULL,
|
|
27
|
+
resource_id TEXT NOT NULL,
|
|
28
|
+
subject_type TEXT NOT NULL CHECK (subject_type IN ('user', 'group')),
|
|
29
|
+
subject_id TEXT NOT NULL,
|
|
30
|
+
level TEXT NOT NULL CHECK (level IN ('read', 'write')),
|
|
31
|
+
granted_by TEXT NOT NULL,
|
|
32
|
+
granted_at TEXT NOT NULL,
|
|
33
|
+
note TEXT NOT NULL DEFAULT '',
|
|
34
|
+
-- One grant per subject per resource. This is what makes raising
|
|
35
|
+
-- read to write an UPDATE the audit trail can describe, instead of
|
|
36
|
+
-- two rows where revoking one leaves access in place.
|
|
37
|
+
UNIQUE (app, kind, resource_id, subject_type, subject_id)
|
|
38
|
+
)
|
|
39
|
+
`,
|
|
40
|
+
// The hot read is the resolver's: "does this subject hold a grant on this
|
|
41
|
+
// row" — correlated per row of a list query, so it must be an index hit.
|
|
42
|
+
`CREATE INDEX IF NOT EXISTS idx_share_grants_subject
|
|
43
|
+
ON ${SHARE_GRANTS_TABLE} (subject_type, subject_id, app, kind, resource_id)`,
|
|
44
|
+
// …and the panel's: "who can see this record".
|
|
45
|
+
`CREATE INDEX IF NOT EXISTS idx_share_grants_resource
|
|
46
|
+
ON ${SHARE_GRANTS_TABLE} (app, kind, resource_id)`,
|
|
47
|
+
`
|
|
48
|
+
CREATE TABLE IF NOT EXISTS ${SHARE_GROUPS_TABLE} (
|
|
49
|
+
group_id TEXT PRIMARY KEY,
|
|
50
|
+
name TEXT NOT NULL,
|
|
51
|
+
created_by TEXT NOT NULL,
|
|
52
|
+
created_at TEXT NOT NULL,
|
|
53
|
+
note TEXT NOT NULL DEFAULT ''
|
|
54
|
+
)
|
|
55
|
+
`,
|
|
56
|
+
`
|
|
57
|
+
CREATE TABLE IF NOT EXISTS ${SHARE_GROUP_MEMBERS_TABLE} (
|
|
58
|
+
group_id TEXT NOT NULL REFERENCES ${SHARE_GROUPS_TABLE} (group_id) ON DELETE CASCADE,
|
|
59
|
+
subject_id TEXT NOT NULL,
|
|
60
|
+
added_by TEXT NOT NULL,
|
|
61
|
+
added_at TEXT NOT NULL,
|
|
62
|
+
PRIMARY KEY (group_id, subject_id)
|
|
63
|
+
)
|
|
64
|
+
`,
|
|
65
|
+
// "Which groups is this person in" — read by every shared-row query.
|
|
66
|
+
`CREATE INDEX IF NOT EXISTS idx_share_group_members_subject
|
|
67
|
+
ON ${SHARE_GROUP_MEMBERS_TABLE} (subject_id)`,
|
|
68
|
+
`
|
|
69
|
+
CREATE TABLE IF NOT EXISTS ${SHARE_EVENTS_TABLE} (
|
|
70
|
+
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
71
|
+
at TEXT NOT NULL,
|
|
72
|
+
action TEXT NOT NULL,
|
|
73
|
+
app TEXT,
|
|
74
|
+
kind TEXT,
|
|
75
|
+
resource_id TEXT,
|
|
76
|
+
subject_type TEXT NOT NULL CHECK (subject_type IN ('user', 'group')),
|
|
77
|
+
subject_id TEXT NOT NULL,
|
|
78
|
+
level TEXT,
|
|
79
|
+
previous_level TEXT,
|
|
80
|
+
actor TEXT NOT NULL,
|
|
81
|
+
note TEXT NOT NULL DEFAULT ''
|
|
82
|
+
)
|
|
83
|
+
`,
|
|
84
|
+
// "Everything that ever happened to this record", newest first.
|
|
85
|
+
`CREATE INDEX IF NOT EXISTS idx_share_events_resource
|
|
86
|
+
ON ${SHARE_EVENTS_TABLE} (app, kind, resource_id, event_id DESC)`,
|
|
87
|
+
// "Everything this person was ever given" — the offboarding question.
|
|
88
|
+
`CREATE INDEX IF NOT EXISTS idx_share_events_subject
|
|
89
|
+
ON ${SHARE_EVENTS_TABLE} (subject_id, event_id DESC)`,
|
|
90
|
+
];
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* tested with hostile input; a consumer never quotes anything itself.
|
|
27
27
|
*/
|
|
28
28
|
import { levelsMeeting, normalizeSubjectId, type ShareLevel } from "cursedbelt-core/sharing/model";
|
|
29
|
-
import { SHARE_GRANTS_TABLE, SHARE_GROUP_MEMBERS_TABLE } from "./
|
|
29
|
+
import { SHARE_GRANTS_TABLE, SHARE_GROUP_MEMBERS_TABLE } from "./schema.js";
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
32
|
* Quote a string as a SQL literal.
|
|
@@ -0,0 +1,213 @@
|
|
|
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
|
+
import { SHARE_EVENTS_TABLE, SHARE_GRANTS_TABLE, SHARE_GROUPS_TABLE, SHARE_GROUP_MEMBERS_TABLE } from "./schema.js";
|
|
12
|
+
|
|
13
|
+
export interface GrantRow {
|
|
14
|
+
app: string;
|
|
15
|
+
kind: string;
|
|
16
|
+
resource_id: string;
|
|
17
|
+
subject_type: "user" | "group";
|
|
18
|
+
subject_id: string;
|
|
19
|
+
level: ShareLevel;
|
|
20
|
+
granted_by: string;
|
|
21
|
+
granted_at: string;
|
|
22
|
+
note: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface EventRow {
|
|
26
|
+
event_id: number;
|
|
27
|
+
at: string;
|
|
28
|
+
action: ShareAction;
|
|
29
|
+
app: string | null;
|
|
30
|
+
kind: string | null;
|
|
31
|
+
resource_id: string | null;
|
|
32
|
+
subject_type: "user" | "group";
|
|
33
|
+
subject_id: string;
|
|
34
|
+
level: ShareLevel | null;
|
|
35
|
+
previous_level: ShareLevel | null;
|
|
36
|
+
actor: string;
|
|
37
|
+
note: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface GroupRow {
|
|
41
|
+
group_id: string;
|
|
42
|
+
name: string;
|
|
43
|
+
created_by: string;
|
|
44
|
+
created_at: string;
|
|
45
|
+
note: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface GroupMemberRow {
|
|
49
|
+
group_id: string;
|
|
50
|
+
subject_id: string;
|
|
51
|
+
added_by: string;
|
|
52
|
+
added_at: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const toGrant = (row: GrantRow): ShareGrant => ({
|
|
56
|
+
resource: { app: row.app, kind: row.kind, id: row.resource_id },
|
|
57
|
+
subject: { type: row.subject_type, id: row.subject_id },
|
|
58
|
+
level: row.level,
|
|
59
|
+
grantedBy: row.granted_by,
|
|
60
|
+
grantedAt: row.granted_at,
|
|
61
|
+
note: row.note,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
export const toEvent = (row: EventRow): ShareEvent => ({
|
|
65
|
+
id: row.event_id,
|
|
66
|
+
at: row.at,
|
|
67
|
+
action: row.action,
|
|
68
|
+
resource:
|
|
69
|
+
row.app === null || row.kind === null || row.resource_id === null
|
|
70
|
+
? null
|
|
71
|
+
: { app: row.app, kind: row.kind, id: row.resource_id },
|
|
72
|
+
subject: { type: row.subject_type, id: row.subject_id },
|
|
73
|
+
level: row.level,
|
|
74
|
+
previousLevel: row.previous_level,
|
|
75
|
+
actor: row.actor,
|
|
76
|
+
note: row.note,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/** The audit trail's columns, in the order every event insert binds them. */
|
|
80
|
+
const EVENT_COLUMNS =
|
|
81
|
+
"(at, action, app, kind, resource_id, subject_type, subject_id, level, previous_level, actor, note)";
|
|
82
|
+
|
|
83
|
+
/** One grant row's WHERE — the five columns of its UNIQUE key. */
|
|
84
|
+
const ONE_GRANT = "app = ? AND kind = ? AND resource_id = ? AND subject_type = ? AND subject_id = ?";
|
|
85
|
+
const RESOURCE_GRANTS = "app = ? AND kind = ? AND resource_id = ?";
|
|
86
|
+
const SUBJECT_GRANTS = "app = ? AND subject_type = ? AND subject_id = ?";
|
|
87
|
+
const GROUP_GRANTS = "app = ? AND subject_type = 'group' AND subject_id = ?";
|
|
88
|
+
|
|
89
|
+
/** "This viewer, directly or through any group they are in" — binds `subjectId` twice. */
|
|
90
|
+
const VIEWER_MATCH = `( (subject_type = 'user' AND subject_id = ?)
|
|
91
|
+
OR (subject_type = 'group' AND subject_id IN
|
|
92
|
+
(SELECT group_id FROM ${SHARE_GROUP_MEMBERS_TABLE} WHERE subject_id = ?)) )`;
|
|
93
|
+
|
|
94
|
+
export const SHARE_SQL = {
|
|
95
|
+
/** Binds the eleven {@link EVENT_COLUMNS}. */
|
|
96
|
+
insertEvent: `INSERT INTO ${SHARE_EVENTS_TABLE}
|
|
97
|
+
${EVENT_COLUMNS}
|
|
98
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
99
|
+
|
|
100
|
+
selectGrant: `SELECT * FROM ${SHARE_GRANTS_TABLE} WHERE ${ONE_GRANT}`,
|
|
101
|
+
/** Binds app, kind, resource_id, subject_type, subject_id, level, granted_by, granted_at, note. */
|
|
102
|
+
upsertGrant: `INSERT INTO ${SHARE_GRANTS_TABLE}
|
|
103
|
+
(app, kind, resource_id, subject_type, subject_id, level, granted_by, granted_at, note)
|
|
104
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
105
|
+
ON CONFLICT (app, kind, resource_id, subject_type, subject_id)
|
|
106
|
+
DO UPDATE SET level = excluded.level, granted_by = excluded.granted_by,
|
|
107
|
+
granted_at = excluded.granted_at, note = excluded.note`,
|
|
108
|
+
deleteGrant: `DELETE FROM ${SHARE_GRANTS_TABLE} WHERE ${ONE_GRANT}`,
|
|
109
|
+
|
|
110
|
+
selectResourceGrants: `SELECT * FROM ${SHARE_GRANTS_TABLE} WHERE ${RESOURCE_GRANTS}`,
|
|
111
|
+
deleteResourceGrants: `DELETE FROM ${SHARE_GRANTS_TABLE} WHERE ${RESOURCE_GRANTS}`,
|
|
112
|
+
selectSubjectGrants: `SELECT * FROM ${SHARE_GRANTS_TABLE} WHERE ${SUBJECT_GRANTS}`,
|
|
113
|
+
deleteSubjectGrants: `DELETE FROM ${SHARE_GRANTS_TABLE} WHERE ${SUBJECT_GRANTS}`,
|
|
114
|
+
selectGroupGrants: `SELECT * FROM ${SHARE_GRANTS_TABLE} WHERE ${GROUP_GRANTS}`,
|
|
115
|
+
deleteGroupGrants: `DELETE FROM ${SHARE_GRANTS_TABLE} WHERE ${GROUP_GRANTS}`,
|
|
116
|
+
|
|
117
|
+
listForResource: `SELECT * FROM ${SHARE_GRANTS_TABLE}
|
|
118
|
+
WHERE ${RESOURCE_GRANTS}
|
|
119
|
+
-- Strongest first, then oldest. Spelled as a CASE rather than an
|
|
120
|
+
-- ORDER BY on level, which happens to work only because 'write'
|
|
121
|
+
-- sorts after 'read' and would silently mis-order a third level.
|
|
122
|
+
ORDER BY CASE level WHEN 'write' THEN 0 ELSE 1 END, granted_at ASC`,
|
|
123
|
+
listForSubject: `SELECT * FROM ${SHARE_GRANTS_TABLE}
|
|
124
|
+
WHERE ${SUBJECT_GRANTS}
|
|
125
|
+
ORDER BY granted_at DESC`,
|
|
126
|
+
/** Binds app, kind, resource_id, then the viewer twice. */
|
|
127
|
+
accessFor: `SELECT level FROM ${SHARE_GRANTS_TABLE}
|
|
128
|
+
WHERE ${RESOURCE_GRANTS}
|
|
129
|
+
AND ${VIEWER_MATCH}`,
|
|
130
|
+
/** Binds app, kind, each of `levelCount` levels, then the viewer twice. */
|
|
131
|
+
resourceIdsFor: (levelCount: number): string => `SELECT DISTINCT resource_id FROM ${SHARE_GRANTS_TABLE}
|
|
132
|
+
WHERE app = ? AND kind = ? AND level IN (${Array.from({ length: levelCount }, () => "?").join(", ")})
|
|
133
|
+
AND ${VIEWER_MATCH}
|
|
134
|
+
ORDER BY resource_id`,
|
|
135
|
+
|
|
136
|
+
upsertGroup: `INSERT INTO ${SHARE_GROUPS_TABLE} (group_id, name, created_by, created_at, note)
|
|
137
|
+
VALUES (?, ?, ?, ?, ?)
|
|
138
|
+
ON CONFLICT (group_id) DO UPDATE SET name = excluded.name, note = excluded.note`,
|
|
139
|
+
renameGroup: `UPDATE ${SHARE_GROUPS_TABLE} SET name = ? WHERE group_id = ?`,
|
|
140
|
+
deleteGroup: `DELETE FROM ${SHARE_GROUPS_TABLE} WHERE group_id = ?`,
|
|
141
|
+
listGroups: `SELECT * FROM ${SHARE_GROUPS_TABLE} ORDER BY name`,
|
|
142
|
+
insertMember: `INSERT INTO ${SHARE_GROUP_MEMBERS_TABLE} (group_id, subject_id, added_by, added_at)
|
|
143
|
+
VALUES (?, ?, ?, ?) ON CONFLICT (group_id, subject_id) DO NOTHING`,
|
|
144
|
+
deleteMember: `DELETE FROM ${SHARE_GROUP_MEMBERS_TABLE} WHERE group_id = ? AND subject_id = ?`,
|
|
145
|
+
listMembers: `SELECT * FROM ${SHARE_GROUP_MEMBERS_TABLE} WHERE group_id = ? ORDER BY subject_id`,
|
|
146
|
+
groupIdsFor: `SELECT group_id FROM ${SHARE_GROUP_MEMBERS_TABLE} WHERE subject_id = ? ORDER BY group_id`,
|
|
147
|
+
|
|
148
|
+
/** The trail, newest first, under a WHERE built by {@link eventsWhere}; binds its params then the limit. */
|
|
149
|
+
events: (where: string): string =>
|
|
150
|
+
`SELECT * FROM ${SHARE_EVENTS_TABLE} WHERE ${where} ORDER BY event_id DESC LIMIT ?`,
|
|
151
|
+
|
|
152
|
+
// ── The D1 store's atomic forms ────────────────────────────────────────────────────────
|
|
153
|
+
// D1 has no interactive transaction, so "read the grant, then decide what to log" cannot
|
|
154
|
+
// be two round trips without a window between them. These write the audit row FROM the
|
|
155
|
+
// grant row inside one `batch()`, so the decision and the write are one atomic step. They
|
|
156
|
+
// are listed here beside the sync forms they must agree with — `d1.spec.ts` runs both
|
|
157
|
+
// stores through one scenario and compares every table.
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The grant's event, read from the grant as it stands BEFORE the upsert that follows it in
|
|
161
|
+
* the same batch: `regrade` (with the old level) when the level changes, `grant` otherwise.
|
|
162
|
+
* Binds at, level, app, kind, resource_id, subject_type, subject_id, level, level, actor,
|
|
163
|
+
* note, then the five {@link ONE_GRANT} keys.
|
|
164
|
+
*/
|
|
165
|
+
insertGrantEvent: `INSERT INTO ${SHARE_EVENTS_TABLE} ${EVENT_COLUMNS}
|
|
166
|
+
SELECT ?, CASE WHEN g.level IS NOT NULL AND g.level <> ? THEN 'regrade' ELSE 'grant' END,
|
|
167
|
+
?, ?, ?, ?, ?, ?,
|
|
168
|
+
CASE WHEN g.level <> ? THEN g.level ELSE NULL END, ?, ?
|
|
169
|
+
FROM (SELECT 1) AS one
|
|
170
|
+
LEFT JOIN ${SHARE_GRANTS_TABLE} AS g ON g.${ONE_GRANT.replaceAll(" AND ", " AND g.")}
|
|
171
|
+
RETURNING *`,
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* One `revoke` event per grant matching `where`, oldest grant first — written BEFORE the
|
|
175
|
+
* delete that follows it in the same batch. Binds at, actor, note, then `where`'s params.
|
|
176
|
+
*/
|
|
177
|
+
insertRevokeEvents: (where: string): string => `INSERT INTO ${SHARE_EVENTS_TABLE} ${EVENT_COLUMNS}
|
|
178
|
+
SELECT ?, 'revoke', app, kind, resource_id, subject_type, subject_id, level, NULL, ?, ?
|
|
179
|
+
FROM ${SHARE_GRANTS_TABLE} WHERE ${where} ORDER BY grant_id
|
|
180
|
+
RETURNING *`,
|
|
181
|
+
oneGrant: ONE_GRANT,
|
|
182
|
+
resourceGrants: RESOURCE_GRANTS,
|
|
183
|
+
subjectGrants: SUBJECT_GRANTS,
|
|
184
|
+
groupGrants: GROUP_GRANTS,
|
|
185
|
+
} as const;
|
|
186
|
+
|
|
187
|
+
/** The trail's WHERE and its params, bound to one app — the filter both stores apply. */
|
|
188
|
+
export function eventsWhere(
|
|
189
|
+
app: string,
|
|
190
|
+
query: { kind?: string; id?: string; subject?: { type: string; id: string } },
|
|
191
|
+
): { where: string; params: (string | number)[] } {
|
|
192
|
+
const clauses = ["app IS NULL OR app = ?"];
|
|
193
|
+
const params: (string | number)[] = [app];
|
|
194
|
+
if (query.kind !== undefined) {
|
|
195
|
+
clauses.push("kind = ?");
|
|
196
|
+
params.push(query.kind);
|
|
197
|
+
}
|
|
198
|
+
if (query.id !== undefined) {
|
|
199
|
+
clauses.push("resource_id = ?");
|
|
200
|
+
params.push(query.id);
|
|
201
|
+
}
|
|
202
|
+
if (query.subject) {
|
|
203
|
+
clauses.push("subject_type = ? AND subject_id = ?");
|
|
204
|
+
params.push(query.subject.type, query.subject.id);
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
where: clauses.map((clause) => `(${clause})`).join(" AND "),
|
|
208
|
+
params,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Newest first, capped: default 200, never more than 1000, never less than 1. */
|
|
213
|
+
export const eventLimit = (limit: number | undefined): number => Math.max(1, Math.min(limit ?? 200, 1000));
|