@opengeni/db 0.12.6 → 0.13.1

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.
@@ -0,0 +1,624 @@
1
+ import { createHash } from "node:crypto";
2
+ import { WORKSPACE_INSTRUCTION_POLICY_CONTENT_MAX_CHARS } from "@opengeni/contracts";
3
+ import type {
4
+ WorkspaceInstructionPolicyActivationEvent,
5
+ WorkspaceInstructionPolicyActivationResponse,
6
+ WorkspaceInstructionPolicyActivationType,
7
+ WorkspaceInstructionPolicyDiffResponse,
8
+ WorkspaceInstructionPolicyDraftProvenanceSource,
9
+ WorkspaceInstructionPolicyHead,
10
+ WorkspaceInstructionPolicyKind,
11
+ WorkspaceInstructionPolicyListQuery,
12
+ WorkspaceInstructionPolicyListResponse,
13
+ WorkspaceInstructionPolicyProvenanceSource,
14
+ WorkspaceInstructionPolicyRevision,
15
+ WorkspaceInstructionPolicyScope,
16
+ WorkspaceInstructionPolicyTarget,
17
+ } from "@opengeni/contracts";
18
+ import { and, asc, desc, eq, isNull, lt, type SQL } from "drizzle-orm";
19
+ import type { Database } from "./index";
20
+ import { withRlsContext, withWorkspaceRls } from "./index";
21
+ import * as schema from "./schema";
22
+
23
+ type DraftInput = WorkspaceInstructionPolicyTarget & {
24
+ accountId: string;
25
+ workspaceId: string;
26
+ content: string;
27
+ provenanceSource: WorkspaceInstructionPolicyProvenanceSource;
28
+ provenanceSourceId: string | null;
29
+ supersedesRevisionId: string | null;
30
+ createdBySubjectId: string;
31
+ };
32
+
33
+ type CallerDraftInput = Omit<DraftInput, "provenanceSource"> & {
34
+ provenanceSource: WorkspaceInstructionPolicyDraftProvenanceSource;
35
+ };
36
+
37
+ export class WorkspaceInstructionPolicyConflictError extends Error {
38
+ readonly name = "WorkspaceInstructionPolicyConflictError";
39
+ readonly code = "WORKSPACE_INSTRUCTION_POLICY_CONFLICT";
40
+
41
+ constructor(readonly currentHead: WorkspaceInstructionPolicyHead | null) {
42
+ super("The active workspace instruction policy changed in another request");
43
+ }
44
+ }
45
+
46
+ export class WorkspaceInstructionPolicyNotFoundError extends Error {
47
+ readonly name = "WorkspaceInstructionPolicyNotFoundError";
48
+
49
+ constructor(message = "Workspace instruction-policy revision was not found") {
50
+ super(message);
51
+ }
52
+ }
53
+
54
+ export class WorkspaceInstructionPolicyInvalidOperationError extends Error {
55
+ readonly name = "WorkspaceInstructionPolicyInvalidOperationError";
56
+ }
57
+
58
+ export class WorkspaceInstructionPolicyLegacyUnavailableError extends Error {
59
+ readonly name = "WorkspaceInstructionPolicyLegacyUnavailableError";
60
+
61
+ constructor() {
62
+ super("This workspace has no legacy agent instructions to import");
63
+ }
64
+ }
65
+
66
+ function contentHash(content: string): string {
67
+ return createHash("sha256").update(content, "utf8").digest("hex");
68
+ }
69
+
70
+ function iso(value: Date | string): string {
71
+ return (value instanceof Date ? value : new Date(value)).toISOString();
72
+ }
73
+
74
+ type RevisionRow = typeof schema.workspaceInstructionPolicyRevisions.$inferSelect;
75
+ type HeadRow = typeof schema.workspaceInstructionPolicyHeads.$inferSelect;
76
+ type EventRow = typeof schema.workspaceInstructionPolicyActivationEvents.$inferSelect;
77
+
78
+ function revisionFromRow(row: RevisionRow): WorkspaceInstructionPolicyRevision {
79
+ return {
80
+ id: row.id,
81
+ accountId: row.accountId,
82
+ workspaceId: row.workspaceId,
83
+ revision: row.revision,
84
+ kind: row.kind as WorkspaceInstructionPolicyKind,
85
+ scope: row.scope as WorkspaceInstructionPolicyScope,
86
+ roleKey: row.roleKey,
87
+ content: row.content,
88
+ contentHash: row.contentHash,
89
+ provenance: {
90
+ source: row.provenanceSource as WorkspaceInstructionPolicyProvenanceSource,
91
+ sourceId: row.provenanceSourceId,
92
+ },
93
+ supersedesRevisionId: row.supersedesRevisionId,
94
+ createdBySubjectId: row.createdBySubjectId,
95
+ createdAt: iso(row.createdAt),
96
+ };
97
+ }
98
+
99
+ function headFromRow(row: HeadRow): WorkspaceInstructionPolicyHead {
100
+ return {
101
+ workspaceId: row.workspaceId,
102
+ kind: row.kind as WorkspaceInstructionPolicyKind,
103
+ scope: row.scope as WorkspaceInstructionPolicyScope,
104
+ roleKey: row.roleKey,
105
+ revisionId: row.revisionId,
106
+ revision: row.revision,
107
+ contentHash: row.contentHash,
108
+ activationVersion: row.activationVersion,
109
+ activatedAt: iso(row.activatedAt),
110
+ };
111
+ }
112
+
113
+ function eventFromRow(row: EventRow): WorkspaceInstructionPolicyActivationEvent {
114
+ return {
115
+ id: row.id,
116
+ accountId: row.accountId,
117
+ workspaceId: row.workspaceId,
118
+ kind: row.kind as WorkspaceInstructionPolicyKind,
119
+ scope: row.scope as WorkspaceInstructionPolicyScope,
120
+ roleKey: row.roleKey,
121
+ type: row.type as WorkspaceInstructionPolicyActivationType,
122
+ activationVersion: row.activationVersion,
123
+ oldRevision:
124
+ row.oldRevisionId === null
125
+ ? null
126
+ : {
127
+ id: row.oldRevisionId,
128
+ revision: row.oldRevision!,
129
+ contentHash: row.oldContentHash!,
130
+ },
131
+ newRevision: {
132
+ id: row.newRevisionId,
133
+ revision: row.newRevision,
134
+ contentHash: row.newContentHash,
135
+ },
136
+ actorSubjectId: row.actorSubjectId,
137
+ reason: row.reason,
138
+ createdAt: iso(row.createdAt),
139
+ };
140
+ }
141
+
142
+ function headTargetConditions(
143
+ workspaceId: string,
144
+ target: WorkspaceInstructionPolicyTarget,
145
+ ): SQL[] {
146
+ return [
147
+ eq(schema.workspaceInstructionPolicyHeads.workspaceId, workspaceId),
148
+ eq(schema.workspaceInstructionPolicyHeads.kind, target.kind),
149
+ eq(schema.workspaceInstructionPolicyHeads.scope, target.scope),
150
+ target.roleKey === null
151
+ ? isNull(schema.workspaceInstructionPolicyHeads.roleKey)
152
+ : eq(schema.workspaceInstructionPolicyHeads.roleKey, target.roleKey),
153
+ ];
154
+ }
155
+
156
+ function eventTargetConditions(
157
+ workspaceId: string,
158
+ target: WorkspaceInstructionPolicyTarget,
159
+ ): SQL[] {
160
+ return [
161
+ eq(schema.workspaceInstructionPolicyActivationEvents.workspaceId, workspaceId),
162
+ eq(schema.workspaceInstructionPolicyActivationEvents.kind, target.kind),
163
+ eq(schema.workspaceInstructionPolicyActivationEvents.scope, target.scope),
164
+ target.roleKey === null
165
+ ? isNull(schema.workspaceInstructionPolicyActivationEvents.roleKey)
166
+ : eq(schema.workspaceInstructionPolicyActivationEvents.roleKey, target.roleKey),
167
+ ];
168
+ }
169
+
170
+ async function lockWorkspace(
171
+ db: Database,
172
+ input: { accountId: string; workspaceId: string },
173
+ ): Promise<{ agentInstructions: string | null }> {
174
+ const [workspace] = await db
175
+ .select({ agentInstructions: schema.workspaces.agentInstructions })
176
+ .from(schema.workspaces)
177
+ .where(
178
+ and(
179
+ eq(schema.workspaces.id, input.workspaceId),
180
+ eq(schema.workspaces.accountId, input.accountId),
181
+ ),
182
+ )
183
+ .for("update")
184
+ .limit(1);
185
+ if (!workspace) {
186
+ throw new WorkspaceInstructionPolicyNotFoundError("Workspace was not found");
187
+ }
188
+ return workspace;
189
+ }
190
+
191
+ async function getRevisionInTransaction(
192
+ db: Database,
193
+ workspaceId: string,
194
+ revisionId: string,
195
+ ): Promise<RevisionRow | null> {
196
+ const [row] = await db
197
+ .select()
198
+ .from(schema.workspaceInstructionPolicyRevisions)
199
+ .where(
200
+ and(
201
+ eq(schema.workspaceInstructionPolicyRevisions.workspaceId, workspaceId),
202
+ eq(schema.workspaceInstructionPolicyRevisions.id, revisionId),
203
+ ),
204
+ )
205
+ .limit(1);
206
+ return row ?? null;
207
+ }
208
+
209
+ async function getHeadInTransaction(
210
+ db: Database,
211
+ workspaceId: string,
212
+ target: WorkspaceInstructionPolicyTarget,
213
+ ): Promise<HeadRow | null> {
214
+ const [row] = await db
215
+ .select()
216
+ .from(schema.workspaceInstructionPolicyHeads)
217
+ .where(and(...headTargetConditions(workspaceId, target)))
218
+ .for("update")
219
+ .limit(1);
220
+ return row ?? null;
221
+ }
222
+
223
+ function sameTarget(
224
+ left: Pick<WorkspaceInstructionPolicyRevision, "kind" | "scope" | "roleKey">,
225
+ right: WorkspaceInstructionPolicyTarget,
226
+ ): boolean {
227
+ return left.kind === right.kind && left.scope === right.scope && left.roleKey === right.roleKey;
228
+ }
229
+
230
+ async function createDraftInTransaction(
231
+ db: Database,
232
+ input: DraftInput,
233
+ ): Promise<WorkspaceInstructionPolicyRevision> {
234
+ if (input.supersedesRevisionId !== null) {
235
+ const superseded = await getRevisionInTransaction(
236
+ db,
237
+ input.workspaceId,
238
+ input.supersedesRevisionId,
239
+ );
240
+ if (!superseded || !sameTarget(revisionFromRow(superseded), input)) {
241
+ throw new WorkspaceInstructionPolicyInvalidOperationError(
242
+ "A superseded revision must exist in the same workspace and instruction-policy target",
243
+ );
244
+ }
245
+ }
246
+ const [created] = await db
247
+ .insert(schema.workspaceInstructionPolicyRevisions)
248
+ .values({
249
+ accountId: input.accountId,
250
+ workspaceId: input.workspaceId,
251
+ kind: input.kind,
252
+ scope: input.scope,
253
+ roleKey: input.roleKey,
254
+ content: input.content,
255
+ contentHash: contentHash(input.content),
256
+ provenanceSource: input.provenanceSource,
257
+ provenanceSourceId: input.provenanceSourceId,
258
+ supersedesRevisionId: input.supersedesRevisionId,
259
+ createdBySubjectId: input.createdBySubjectId,
260
+ })
261
+ .returning();
262
+ if (!created) throw new Error("Workspace instruction-policy draft was not created");
263
+ return revisionFromRow(created);
264
+ }
265
+
266
+ export async function createWorkspaceInstructionPolicyDraft(
267
+ db: Database,
268
+ input: CallerDraftInput,
269
+ ): Promise<WorkspaceInstructionPolicyRevision> {
270
+ return await withRlsContext(
271
+ db,
272
+ { accountId: input.accountId, workspaceId: input.workspaceId },
273
+ async (scopedDb) => {
274
+ await lockWorkspace(scopedDb, input);
275
+ return await createDraftInTransaction(scopedDb, input);
276
+ },
277
+ );
278
+ }
279
+
280
+ /** Import only the stored legacy override; never materialize a deployment default or activate it. */
281
+ export async function importLegacyWorkspaceInstructionPolicyDraft(
282
+ db: Database,
283
+ input: {
284
+ accountId: string;
285
+ workspaceId: string;
286
+ createdBySubjectId: string;
287
+ supersedesRevisionId: string | null;
288
+ },
289
+ ): Promise<WorkspaceInstructionPolicyRevision> {
290
+ return await withRlsContext(
291
+ db,
292
+ { accountId: input.accountId, workspaceId: input.workspaceId },
293
+ async (scopedDb) => {
294
+ const workspace = await lockWorkspace(scopedDb, input);
295
+ if (workspace.agentInstructions === null) {
296
+ throw new WorkspaceInstructionPolicyLegacyUnavailableError();
297
+ }
298
+ if (
299
+ workspace.agentInstructions.trim().length === 0 ||
300
+ workspace.agentInstructions.length > WORKSPACE_INSTRUCTION_POLICY_CONTENT_MAX_CHARS
301
+ ) {
302
+ throw new WorkspaceInstructionPolicyInvalidOperationError(
303
+ "Legacy agent instructions do not fit the workspace instruction-policy draft contract",
304
+ );
305
+ }
306
+ return await createDraftInTransaction(scopedDb, {
307
+ ...input,
308
+ kind: "charter",
309
+ scope: "global",
310
+ roleKey: null,
311
+ content: workspace.agentInstructions,
312
+ provenanceSource: "legacy_import",
313
+ provenanceSourceId: "workspaces.agent_instructions",
314
+ });
315
+ },
316
+ );
317
+ }
318
+
319
+ function listFilterConditions(
320
+ workspaceId: string,
321
+ query: WorkspaceInstructionPolicyListQuery,
322
+ ): SQL[] {
323
+ const conditions: SQL[] = [
324
+ eq(schema.workspaceInstructionPolicyRevisions.workspaceId, workspaceId),
325
+ ];
326
+ if (query.kind !== undefined) {
327
+ conditions.push(eq(schema.workspaceInstructionPolicyRevisions.kind, query.kind));
328
+ }
329
+ if (query.scope !== undefined) {
330
+ conditions.push(eq(schema.workspaceInstructionPolicyRevisions.scope, query.scope));
331
+ }
332
+ if (query.roleKey !== undefined) {
333
+ conditions.push(
334
+ query.roleKey === null
335
+ ? isNull(schema.workspaceInstructionPolicyRevisions.roleKey)
336
+ : eq(schema.workspaceInstructionPolicyRevisions.roleKey, query.roleKey),
337
+ );
338
+ }
339
+ if (query.afterRevision !== undefined) {
340
+ conditions.push(lt(schema.workspaceInstructionPolicyRevisions.revision, query.afterRevision));
341
+ }
342
+ return conditions;
343
+ }
344
+
345
+ export async function listWorkspaceInstructionPolicyRevisions(
346
+ db: Database,
347
+ workspaceId: string,
348
+ query: WorkspaceInstructionPolicyListQuery,
349
+ ): Promise<WorkspaceInstructionPolicyListResponse> {
350
+ return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
351
+ const rows = await scopedDb
352
+ .select()
353
+ .from(schema.workspaceInstructionPolicyRevisions)
354
+ .where(and(...listFilterConditions(workspaceId, query)))
355
+ .orderBy(desc(schema.workspaceInstructionPolicyRevisions.revision))
356
+ .limit(query.limit + 1);
357
+ const hasMore = rows.length > query.limit;
358
+ const page = rows.slice(0, query.limit);
359
+ const [heads, events] = await Promise.all([
360
+ scopedDb
361
+ .select()
362
+ .from(schema.workspaceInstructionPolicyHeads)
363
+ .where(eq(schema.workspaceInstructionPolicyHeads.workspaceId, workspaceId))
364
+ .orderBy(
365
+ asc(schema.workspaceInstructionPolicyHeads.kind),
366
+ asc(schema.workspaceInstructionPolicyHeads.scope),
367
+ asc(schema.workspaceInstructionPolicyHeads.roleKey),
368
+ ),
369
+ scopedDb
370
+ .select()
371
+ .from(schema.workspaceInstructionPolicyActivationEvents)
372
+ .where(eq(schema.workspaceInstructionPolicyActivationEvents.workspaceId, workspaceId))
373
+ .orderBy(
374
+ desc(schema.workspaceInstructionPolicyActivationEvents.createdAt),
375
+ desc(schema.workspaceInstructionPolicyActivationEvents.id),
376
+ )
377
+ .limit(query.limit),
378
+ ]);
379
+ return {
380
+ revisions: page.map(revisionFromRow),
381
+ activeHeads: heads.map(headFromRow),
382
+ activationEvents: events.map(eventFromRow),
383
+ nextAfterRevision: hasMore ? page.at(-1)!.revision : null,
384
+ };
385
+ });
386
+ }
387
+
388
+ export async function getWorkspaceInstructionPolicyRevision(
389
+ db: Database,
390
+ workspaceId: string,
391
+ revisionId: string,
392
+ ): Promise<WorkspaceInstructionPolicyRevision> {
393
+ return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
394
+ const row = await getRevisionInTransaction(scopedDb, workspaceId, revisionId);
395
+ if (!row) throw new WorkspaceInstructionPolicyNotFoundError();
396
+ return revisionFromRow(row);
397
+ });
398
+ }
399
+
400
+ function splitLines(content: string): string[] {
401
+ return content.length === 0 ? [] : content.split("\n");
402
+ }
403
+
404
+ /** Deterministic, dependency-free unified line diff; large inputs degrade to a full replacement. */
405
+ export function diffWorkspaceInstructionPolicyContent(
406
+ from: WorkspaceInstructionPolicyRevision,
407
+ to: WorkspaceInstructionPolicyRevision,
408
+ ): string {
409
+ const header = `--- revision-${from.revision}\n+++ revision-${to.revision}\n`;
410
+ if (from.content === to.content) return header;
411
+ const before = splitLines(from.content);
412
+ const after = splitLines(to.content);
413
+ const operations: Array<{ prefix: " " | "+" | "-"; line: string }> = [];
414
+ if ((before.length + 1) * (after.length + 1) <= 1_000_000) {
415
+ const width = after.length + 1;
416
+ const table = new Uint32Array((before.length + 1) * width);
417
+ for (let left = before.length - 1; left >= 0; left -= 1) {
418
+ for (let right = after.length - 1; right >= 0; right -= 1) {
419
+ const offset = left * width + right;
420
+ table[offset] =
421
+ before[left] === after[right]
422
+ ? table[(left + 1) * width + right + 1]! + 1
423
+ : Math.max(table[(left + 1) * width + right]!, table[left * width + right + 1]!);
424
+ }
425
+ }
426
+ let left = 0;
427
+ let right = 0;
428
+ while (left < before.length || right < after.length) {
429
+ if (left < before.length && right < after.length && before[left] === after[right]) {
430
+ operations.push({ prefix: " ", line: before[left]! });
431
+ left += 1;
432
+ right += 1;
433
+ } else if (
434
+ right < after.length &&
435
+ (left === before.length ||
436
+ table[left * width + right + 1]! >= table[(left + 1) * width + right]!)
437
+ ) {
438
+ operations.push({ prefix: "+", line: after[right]! });
439
+ right += 1;
440
+ } else {
441
+ operations.push({ prefix: "-", line: before[left]! });
442
+ left += 1;
443
+ }
444
+ }
445
+ } else {
446
+ operations.push(...before.map((line) => ({ prefix: "-" as const, line })));
447
+ operations.push(...after.map((line) => ({ prefix: "+" as const, line })));
448
+ }
449
+ const hunk = `@@ -1,${before.length} +1,${after.length} @@\n`;
450
+ return `${header}${hunk}${operations.map(({ prefix, line }) => `${prefix}${line}\n`).join("")}`;
451
+ }
452
+
453
+ export async function diffWorkspaceInstructionPolicyRevisions(
454
+ db: Database,
455
+ workspaceId: string,
456
+ input: { fromRevisionId: string; toRevisionId: string },
457
+ ): Promise<WorkspaceInstructionPolicyDiffResponse> {
458
+ return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
459
+ const [fromRow, toRow] = await Promise.all([
460
+ getRevisionInTransaction(scopedDb, workspaceId, input.fromRevisionId),
461
+ getRevisionInTransaction(scopedDb, workspaceId, input.toRevisionId),
462
+ ]);
463
+ if (!fromRow || !toRow) throw new WorkspaceInstructionPolicyNotFoundError();
464
+ const from = revisionFromRow(fromRow);
465
+ const to = revisionFromRow(toRow);
466
+ if (!sameTarget(from, to)) {
467
+ throw new WorkspaceInstructionPolicyInvalidOperationError(
468
+ "Instruction-policy diffs require revisions from the same target",
469
+ );
470
+ }
471
+ return { from, to, format: "unified", diff: diffWorkspaceInstructionPolicyContent(from, to) };
472
+ });
473
+ }
474
+
475
+ async function changeActiveRevision(
476
+ db: Database,
477
+ input: {
478
+ accountId: string;
479
+ workspaceId: string;
480
+ targetRevisionId: string;
481
+ expectedCurrentRevisionId: string | null;
482
+ actorSubjectId: string;
483
+ reason: string;
484
+ type: WorkspaceInstructionPolicyActivationType;
485
+ },
486
+ ): Promise<WorkspaceInstructionPolicyActivationResponse> {
487
+ return await withRlsContext(
488
+ db,
489
+ { accountId: input.accountId, workspaceId: input.workspaceId },
490
+ async (scopedDb) => {
491
+ // This row lock serializes both an absent-head first activation and later
492
+ // updates, so every loser can return the authoritative typed conflict.
493
+ await lockWorkspace(scopedDb, input);
494
+ const targetRow = await getRevisionInTransaction(
495
+ scopedDb,
496
+ input.workspaceId,
497
+ input.targetRevisionId,
498
+ );
499
+ if (!targetRow) throw new WorkspaceInstructionPolicyNotFoundError();
500
+ const target = revisionFromRow(targetRow);
501
+ const targetIdentity: WorkspaceInstructionPolicyTarget = {
502
+ kind: target.kind,
503
+ scope: target.scope,
504
+ roleKey: target.roleKey,
505
+ };
506
+ const currentRow = await getHeadInTransaction(scopedDb, input.workspaceId, targetIdentity);
507
+ const currentHead = currentRow ? headFromRow(currentRow) : null;
508
+ if ((currentHead?.revisionId ?? null) !== input.expectedCurrentRevisionId) {
509
+ throw new WorkspaceInstructionPolicyConflictError(currentHead);
510
+ }
511
+ if (currentHead?.revisionId === target.id) {
512
+ throw new WorkspaceInstructionPolicyInvalidOperationError(
513
+ "The requested instruction-policy revision is already active",
514
+ );
515
+ }
516
+ if (input.type === "rollback") {
517
+ if (!currentHead) {
518
+ throw new WorkspaceInstructionPolicyInvalidOperationError(
519
+ "A workspace instruction policy with no active head cannot be rolled back",
520
+ );
521
+ }
522
+ const [previousActivation] = await scopedDb
523
+ .select({ id: schema.workspaceInstructionPolicyActivationEvents.id })
524
+ .from(schema.workspaceInstructionPolicyActivationEvents)
525
+ .where(
526
+ and(
527
+ ...eventTargetConditions(input.workspaceId, targetIdentity),
528
+ eq(schema.workspaceInstructionPolicyActivationEvents.newRevisionId, target.id),
529
+ ),
530
+ )
531
+ .limit(1);
532
+ if (!previousActivation) {
533
+ throw new WorkspaceInstructionPolicyInvalidOperationError(
534
+ "Rollback targets must have been active previously",
535
+ );
536
+ }
537
+ }
538
+ const activationVersion = (currentHead?.activationVersion ?? 0) + 1;
539
+ const createdAt = new Date();
540
+ const [eventRow] = await scopedDb
541
+ .insert(schema.workspaceInstructionPolicyActivationEvents)
542
+ .values({
543
+ accountId: input.accountId,
544
+ workspaceId: input.workspaceId,
545
+ ...targetIdentity,
546
+ type: input.type,
547
+ activationVersion,
548
+ oldRevisionId: currentHead?.revisionId ?? null,
549
+ oldRevision: currentHead?.revision ?? null,
550
+ oldContentHash: currentHead?.contentHash ?? null,
551
+ newRevisionId: target.id,
552
+ newRevision: target.revision,
553
+ newContentHash: target.contentHash,
554
+ actorSubjectId: input.actorSubjectId,
555
+ reason: input.reason,
556
+ createdAt,
557
+ })
558
+ .returning();
559
+ if (!eventRow) throw new Error("Instruction-policy activation event was not recorded");
560
+ let headRow: HeadRow | undefined;
561
+ if (currentRow) {
562
+ [headRow] = await scopedDb
563
+ .update(schema.workspaceInstructionPolicyHeads)
564
+ .set({
565
+ revisionId: target.id,
566
+ revision: target.revision,
567
+ contentHash: target.contentHash,
568
+ activationVersion,
569
+ activatedAt: createdAt,
570
+ })
571
+ .where(eq(schema.workspaceInstructionPolicyHeads.id, currentRow.id))
572
+ .returning();
573
+ } else {
574
+ [headRow] = await scopedDb
575
+ .insert(schema.workspaceInstructionPolicyHeads)
576
+ .values({
577
+ accountId: input.accountId,
578
+ workspaceId: input.workspaceId,
579
+ ...targetIdentity,
580
+ revisionId: target.id,
581
+ revision: target.revision,
582
+ contentHash: target.contentHash,
583
+ activationVersion,
584
+ activatedAt: createdAt,
585
+ })
586
+ .returning();
587
+ }
588
+ if (!headRow) throw new Error("Instruction-policy activation head was not recorded");
589
+ return { head: headFromRow(headRow), event: eventFromRow(eventRow) };
590
+ },
591
+ );
592
+ }
593
+
594
+ export async function activateWorkspaceInstructionPolicyRevision(
595
+ db: Database,
596
+ input: {
597
+ accountId: string;
598
+ workspaceId: string;
599
+ revisionId: string;
600
+ expectedCurrentRevisionId: string | null;
601
+ actorSubjectId: string;
602
+ reason: string;
603
+ },
604
+ ): Promise<WorkspaceInstructionPolicyActivationResponse> {
605
+ return await changeActiveRevision(db, {
606
+ ...input,
607
+ targetRevisionId: input.revisionId,
608
+ type: "activate",
609
+ });
610
+ }
611
+
612
+ export async function rollbackWorkspaceInstructionPolicyRevision(
613
+ db: Database,
614
+ input: {
615
+ accountId: string;
616
+ workspaceId: string;
617
+ targetRevisionId: string;
618
+ expectedCurrentRevisionId: string;
619
+ actorSubjectId: string;
620
+ reason: string;
621
+ },
622
+ ): Promise<WorkspaceInstructionPolicyActivationResponse> {
623
+ return await changeActiveRevision(db, { ...input, type: "rollback" });
624
+ }