cursedbelt-server 4.27.0 → 4.29.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/metrics/metricsBuffer.d.ts +0 -2
- package/dist/server/metrics/metricsBuffer.js +5 -2
- package/dist/server/metrics/requestPlace.d.ts +8 -0
- package/dist/server/metrics/requestPlace.js +8 -0
- 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/requestLog/requestMetrics.js +1 -1
- 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/dist/server/telemetry.d.ts +1 -1
- package/dist/server/telemetry.js +1 -1
- package/docs/activity.md +7 -0
- package/docs/notifications.md +9 -0
- package/package.json +19 -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/metrics/metricsBuffer.ts +5 -2
- package/src/server/metrics/requestPlace.ts +9 -0
- 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/requestLog/requestMetrics.ts +1 -1
- 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/telemetry.ts +1 -0
- package/src/server/telemetryIsWorkerSafe.spec.ts +54 -0
|
@@ -0,0 +1,19 @@
|
|
|
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
|
+
export declare const SHARE_GRANTS_TABLE = "share_grants";
|
|
11
|
+
export declare const SHARE_GROUPS_TABLE = "share_groups";
|
|
12
|
+
export declare const SHARE_GROUP_MEMBERS_TABLE = "share_group_members";
|
|
13
|
+
export declare const SHARE_EVENTS_TABLE = "share_events";
|
|
14
|
+
/**
|
|
15
|
+
* Every statement of the `0001_share_grants` migration, in order. Idempotent (`IF NOT
|
|
16
|
+
* EXISTS` throughout), so a schema file built from it can be applied to a database that
|
|
17
|
+
* already has the tables.
|
|
18
|
+
*/
|
|
19
|
+
export declare const SHARE_DDL: readonly string[];
|
|
@@ -0,0 +1,88 @@
|
|
|
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
|
+
export const SHARE_GRANTS_TABLE = "share_grants";
|
|
11
|
+
export const SHARE_GROUPS_TABLE = "share_groups";
|
|
12
|
+
export const SHARE_GROUP_MEMBERS_TABLE = "share_group_members";
|
|
13
|
+
export const SHARE_EVENTS_TABLE = "share_events";
|
|
14
|
+
/**
|
|
15
|
+
* Every statement of the `0001_share_grants` migration, in order. Idempotent (`IF NOT
|
|
16
|
+
* EXISTS` throughout), so a schema file built from it can be applied to a database that
|
|
17
|
+
* already has the tables.
|
|
18
|
+
*/
|
|
19
|
+
export const SHARE_DDL = [
|
|
20
|
+
`
|
|
21
|
+
CREATE TABLE IF NOT EXISTS ${SHARE_GRANTS_TABLE} (
|
|
22
|
+
grant_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
23
|
+
app TEXT NOT NULL,
|
|
24
|
+
kind TEXT NOT NULL,
|
|
25
|
+
resource_id TEXT NOT NULL,
|
|
26
|
+
subject_type TEXT NOT NULL CHECK (subject_type IN ('user', 'group')),
|
|
27
|
+
subject_id TEXT NOT NULL,
|
|
28
|
+
level TEXT NOT NULL CHECK (level IN ('read', 'write')),
|
|
29
|
+
granted_by TEXT NOT NULL,
|
|
30
|
+
granted_at TEXT NOT NULL,
|
|
31
|
+
note TEXT NOT NULL DEFAULT '',
|
|
32
|
+
-- One grant per subject per resource. This is what makes raising
|
|
33
|
+
-- read to write an UPDATE the audit trail can describe, instead of
|
|
34
|
+
-- two rows where revoking one leaves access in place.
|
|
35
|
+
UNIQUE (app, kind, resource_id, subject_type, subject_id)
|
|
36
|
+
)
|
|
37
|
+
`,
|
|
38
|
+
// The hot read is the resolver's: "does this subject hold a grant on this
|
|
39
|
+
// row" — correlated per row of a list query, so it must be an index hit.
|
|
40
|
+
`CREATE INDEX IF NOT EXISTS idx_share_grants_subject
|
|
41
|
+
ON ${SHARE_GRANTS_TABLE} (subject_type, subject_id, app, kind, resource_id)`,
|
|
42
|
+
// …and the panel's: "who can see this record".
|
|
43
|
+
`CREATE INDEX IF NOT EXISTS idx_share_grants_resource
|
|
44
|
+
ON ${SHARE_GRANTS_TABLE} (app, kind, resource_id)`,
|
|
45
|
+
`
|
|
46
|
+
CREATE TABLE IF NOT EXISTS ${SHARE_GROUPS_TABLE} (
|
|
47
|
+
group_id TEXT PRIMARY KEY,
|
|
48
|
+
name TEXT NOT NULL,
|
|
49
|
+
created_by TEXT NOT NULL,
|
|
50
|
+
created_at TEXT NOT NULL,
|
|
51
|
+
note TEXT NOT NULL DEFAULT ''
|
|
52
|
+
)
|
|
53
|
+
`,
|
|
54
|
+
`
|
|
55
|
+
CREATE TABLE IF NOT EXISTS ${SHARE_GROUP_MEMBERS_TABLE} (
|
|
56
|
+
group_id TEXT NOT NULL REFERENCES ${SHARE_GROUPS_TABLE} (group_id) ON DELETE CASCADE,
|
|
57
|
+
subject_id TEXT NOT NULL,
|
|
58
|
+
added_by TEXT NOT NULL,
|
|
59
|
+
added_at TEXT NOT NULL,
|
|
60
|
+
PRIMARY KEY (group_id, subject_id)
|
|
61
|
+
)
|
|
62
|
+
`,
|
|
63
|
+
// "Which groups is this person in" — read by every shared-row query.
|
|
64
|
+
`CREATE INDEX IF NOT EXISTS idx_share_group_members_subject
|
|
65
|
+
ON ${SHARE_GROUP_MEMBERS_TABLE} (subject_id)`,
|
|
66
|
+
`
|
|
67
|
+
CREATE TABLE IF NOT EXISTS ${SHARE_EVENTS_TABLE} (
|
|
68
|
+
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
69
|
+
at TEXT NOT NULL,
|
|
70
|
+
action TEXT NOT NULL,
|
|
71
|
+
app TEXT,
|
|
72
|
+
kind TEXT,
|
|
73
|
+
resource_id TEXT,
|
|
74
|
+
subject_type TEXT NOT NULL CHECK (subject_type IN ('user', 'group')),
|
|
75
|
+
subject_id TEXT NOT NULL,
|
|
76
|
+
level TEXT,
|
|
77
|
+
previous_level TEXT,
|
|
78
|
+
actor TEXT NOT NULL,
|
|
79
|
+
note TEXT NOT NULL DEFAULT ''
|
|
80
|
+
)
|
|
81
|
+
`,
|
|
82
|
+
// "Everything that ever happened to this record", newest first.
|
|
83
|
+
`CREATE INDEX IF NOT EXISTS idx_share_events_resource
|
|
84
|
+
ON ${SHARE_EVENTS_TABLE} (app, kind, resource_id, event_id DESC)`,
|
|
85
|
+
// "Everything this person was ever given" — the offboarding question.
|
|
86
|
+
`CREATE INDEX IF NOT EXISTS idx_share_events_subject
|
|
87
|
+
ON ${SHARE_EVENTS_TABLE} (subject_id, event_id DESC)`,
|
|
88
|
+
];
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* tested with hostile input; a consumer never quotes anything itself.
|
|
27
27
|
*/
|
|
28
28
|
import { levelsMeeting, normalizeSubjectId } 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
|
* Quote a string as a SQL literal.
|
|
32
32
|
*
|
|
@@ -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.
|