@opengeni/db 0.16.2 → 0.18.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.
package/src/index.ts CHANGED
@@ -153,6 +153,7 @@ import {
153
153
  asc,
154
154
  desc,
155
155
  eq,
156
+ getTableColumns,
156
157
  gt,
157
158
  gte,
158
159
  inArray,
@@ -1190,6 +1191,8 @@ export const allWorkspacePermissions: Permission[] = [
1190
1191
  "goals:manage",
1191
1192
  "enrollments:read",
1192
1193
  "enrollments:manage",
1194
+ "artifacts:read",
1195
+ "artifacts:publish",
1193
1196
  ];
1194
1197
 
1195
1198
  export const allAccountPermissions: Permission[] = [
@@ -3379,10 +3382,31 @@ export type CreateSocialConnectionInput = {
3379
3382
  status: SocialConnectionStatus;
3380
3383
  scopes?: string[];
3381
3384
  credentialRef?: string | null;
3385
+ credentialEncrypted?: string | null;
3382
3386
  tokenMetadata?: Record<string, unknown>;
3383
3387
  metadata?: Record<string, unknown>;
3384
3388
  };
3385
3389
 
3390
+ export type UpsertSocialOAuthConnectionInput = {
3391
+ accountId: string;
3392
+ workspaceId: string;
3393
+ provider: SocialProvider;
3394
+ accountHandle: string;
3395
+ accountName?: string | null;
3396
+ externalAccountId?: string | null;
3397
+ scopes: string[];
3398
+ credentialEncrypted: string;
3399
+ tokenMetadata?: Record<string, unknown>;
3400
+ };
3401
+
3402
+ export type UpdateSocialConnectionCredentialInput = {
3403
+ workspaceId: string;
3404
+ connectionId: string;
3405
+ credentialEncrypted?: string | null;
3406
+ status?: SocialConnectionStatus;
3407
+ tokenMetadata?: Record<string, unknown>;
3408
+ };
3409
+
3386
3410
  export type CreateSocialPostInput = {
3387
3411
  accountId: string;
3388
3412
  workspaceId: string;
@@ -5083,6 +5107,21 @@ function connectionExactSubject(subjectId?: string | null): SQL {
5083
5107
  : isNull(schema.connections.subjectId);
5084
5108
  }
5085
5109
 
5110
+ function personalSlackCanonicalConnectionOrder(): SQL[] {
5111
+ return [
5112
+ sql`case ${schema.connections.status}
5113
+ when 'active' then 0
5114
+ when 'needs_reauth' then 1
5115
+ when 'error' then 2
5116
+ when 'revoked' then 3
5117
+ else 4
5118
+ end`,
5119
+ desc(schema.connections.updatedAt),
5120
+ desc(schema.connections.createdAt),
5121
+ desc(schema.connections.id),
5122
+ ];
5123
+ }
5124
+
5086
5125
  async function withConnectionSubjectRls<T>(
5087
5126
  db: Database,
5088
5127
  workspaceId: string,
@@ -5297,6 +5336,941 @@ export async function revokeConnection(
5297
5336
  );
5298
5337
  }
5299
5338
 
5339
+ export type SlackInstallationRoute = {
5340
+ accountId: string;
5341
+ workspaceId: string;
5342
+ connectionId: string;
5343
+ botId: string;
5344
+ botUserId: string;
5345
+ };
5346
+
5347
+ export type SlackBotUserLink = {
5348
+ id: string;
5349
+ accountId: string;
5350
+ workspaceId: string;
5351
+ connectionId: string;
5352
+ slackTeamId: string;
5353
+ slackUserId: string;
5354
+ subjectId: string;
5355
+ linkedBySubjectId: string;
5356
+ createdAt: Date;
5357
+ updatedAt: Date;
5358
+ };
5359
+
5360
+ export type SlackInteractionTriggerKind =
5361
+ | "app_mention"
5362
+ | "dm"
5363
+ | "slash_command"
5364
+ | "message_shortcut"
5365
+ | "thread_reply";
5366
+
5367
+ export type SlackInteractionInboxEntry = {
5368
+ id: string;
5369
+ accountId: string;
5370
+ workspaceId: string;
5371
+ connectionId: string;
5372
+ providerEventId: string;
5373
+ providerMessageId: string;
5374
+ slackTeamId: string;
5375
+ slackUserId: string;
5376
+ slackChannelId: string;
5377
+ slackMessageTs: string;
5378
+ slackThreadTs: string | null;
5379
+ triggerKind: SlackInteractionTriggerKind;
5380
+ text: string;
5381
+ status: "pending" | "processing" | "processed" | "failed";
5382
+ claimHolderId: string | null;
5383
+ claimExpiresAt: Date | null;
5384
+ attemptCount: number;
5385
+ retryAt: Date | null;
5386
+ lastErrorCode: string | null;
5387
+ processedAt: Date | null;
5388
+ createdAt: Date;
5389
+ updatedAt: Date;
5390
+ };
5391
+
5392
+ export type SlackInteraction = {
5393
+ id: string;
5394
+ accountId: string;
5395
+ workspaceId: string;
5396
+ connectionId: string;
5397
+ slackTeamId: string;
5398
+ slackChannelId: string;
5399
+ slackThreadTs: string;
5400
+ routeKey: string;
5401
+ triggeringProviderEventId: string;
5402
+ owningSubjectId: string;
5403
+ visibility: "private" | "workspace";
5404
+ sessionReservationId: string;
5405
+ sessionId: string | null;
5406
+ lastDeliveredSessionEventSequence: number;
5407
+ deliveryClaimHolderId: string | null;
5408
+ deliveryClaimExpiresAt: Date | null;
5409
+ deliveryAttemptCount: number;
5410
+ deliveryRetryAt: Date | null;
5411
+ deliveryLastErrorCode: string | null;
5412
+ ackSlackMessageTs: string | null;
5413
+ progressCount: number;
5414
+ terminalDeliveryState: "open" | "completed" | "failed" | "cancelled" | "blocked";
5415
+ createdAt: Date;
5416
+ updatedAt: Date;
5417
+ };
5418
+
5419
+ export type SlackInteractionProgressDelivery = {
5420
+ id: string;
5421
+ accountId: string;
5422
+ workspaceId: string;
5423
+ interactionId: string;
5424
+ sessionEventSequence: number;
5425
+ slot: number;
5426
+ operationId: string;
5427
+ createdAt: Date;
5428
+ };
5429
+
5430
+ export async function resolveSlackInstallationRoute(
5431
+ db: Database,
5432
+ slackTeamId: string,
5433
+ ): Promise<SlackInstallationRoute | null> {
5434
+ const rows = await db.execute<{
5435
+ account_id: string;
5436
+ workspace_id: string;
5437
+ connection_id: string;
5438
+ bot_id: string;
5439
+ bot_user_id: string;
5440
+ }>(sql`select * from opengeni_private.resolve_slack_installation(${slackTeamId})`);
5441
+ const row = rows[0];
5442
+ return row
5443
+ ? {
5444
+ accountId: row.account_id,
5445
+ workspaceId: row.workspace_id,
5446
+ connectionId: row.connection_id,
5447
+ botId: row.bot_id,
5448
+ botUserId: row.bot_user_id,
5449
+ }
5450
+ : null;
5451
+ }
5452
+
5453
+ export async function saveSlackBotUserLink(
5454
+ db: Database,
5455
+ input: Omit<SlackBotUserLink, "id" | "createdAt" | "updatedAt">,
5456
+ ): Promise<SlackBotUserLink> {
5457
+ return await withWorkspaceRls(db, input.workspaceId, async (scopedDb) => {
5458
+ const [row] = await scopedDb
5459
+ .insert(schema.slackBotUserLinks)
5460
+ .values(input)
5461
+ .onConflictDoUpdate({
5462
+ target: [schema.slackBotUserLinks.connectionId, schema.slackBotUserLinks.slackUserId],
5463
+ set: {
5464
+ accountId: input.accountId,
5465
+ workspaceId: input.workspaceId,
5466
+ slackTeamId: input.slackTeamId,
5467
+ subjectId: input.subjectId,
5468
+ linkedBySubjectId: input.linkedBySubjectId,
5469
+ updatedAt: sql`now()`,
5470
+ },
5471
+ })
5472
+ .returning();
5473
+ if (!row) throw new Error("Slack identity link write returned no row");
5474
+ return mapSlackBotUserLink(row);
5475
+ });
5476
+ }
5477
+
5478
+ export async function getSlackBotUserLink(
5479
+ db: Database,
5480
+ workspaceId: string,
5481
+ connectionId: string,
5482
+ slackUserId: string,
5483
+ ): Promise<SlackBotUserLink | null> {
5484
+ return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
5485
+ const [row] = await scopedDb
5486
+ .select()
5487
+ .from(schema.slackBotUserLinks)
5488
+ .where(
5489
+ and(
5490
+ eq(schema.slackBotUserLinks.workspaceId, workspaceId),
5491
+ eq(schema.slackBotUserLinks.connectionId, connectionId),
5492
+ eq(schema.slackBotUserLinks.slackUserId, slackUserId),
5493
+ ),
5494
+ )
5495
+ .limit(1);
5496
+ return row ? mapSlackBotUserLink(row) : null;
5497
+ });
5498
+ }
5499
+
5500
+ export async function deleteSlackBotUserLink(
5501
+ db: Database,
5502
+ workspaceId: string,
5503
+ connectionId: string,
5504
+ slackUserId: string,
5505
+ ): Promise<boolean> {
5506
+ return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
5507
+ const rows = await scopedDb
5508
+ .delete(schema.slackBotUserLinks)
5509
+ .where(
5510
+ and(
5511
+ eq(schema.slackBotUserLinks.workspaceId, workspaceId),
5512
+ eq(schema.slackBotUserLinks.connectionId, connectionId),
5513
+ eq(schema.slackBotUserLinks.slackUserId, slackUserId),
5514
+ ),
5515
+ )
5516
+ .returning({ id: schema.slackBotUserLinks.id });
5517
+ return rows.length > 0;
5518
+ });
5519
+ }
5520
+
5521
+ export async function enqueueSlackInteractionInbox(
5522
+ db: Database,
5523
+ input: Omit<
5524
+ SlackInteractionInboxEntry,
5525
+ | "id"
5526
+ | "status"
5527
+ | "claimHolderId"
5528
+ | "claimExpiresAt"
5529
+ | "attemptCount"
5530
+ | "retryAt"
5531
+ | "lastErrorCode"
5532
+ | "processedAt"
5533
+ | "createdAt"
5534
+ | "updatedAt"
5535
+ >,
5536
+ ): Promise<{ inserted: boolean; entry: SlackInteractionInboxEntry }> {
5537
+ return await withRlsContext(
5538
+ db,
5539
+ { accountId: input.accountId, workspaceId: input.workspaceId },
5540
+ async (scopedDb) => {
5541
+ const [created] = await scopedDb
5542
+ .insert(schema.slackInteractionInbox)
5543
+ .values(input)
5544
+ .onConflictDoNothing()
5545
+ .returning();
5546
+ if (created) return { inserted: true, entry: mapSlackInteractionInbox(created) };
5547
+ const [existing] = await scopedDb
5548
+ .select()
5549
+ .from(schema.slackInteractionInbox)
5550
+ .where(
5551
+ and(
5552
+ eq(schema.slackInteractionInbox.connectionId, input.connectionId),
5553
+ or(
5554
+ eq(schema.slackInteractionInbox.providerEventId, input.providerEventId),
5555
+ eq(schema.slackInteractionInbox.providerMessageId, input.providerMessageId),
5556
+ ),
5557
+ ),
5558
+ )
5559
+ .limit(1);
5560
+ if (!existing) throw new Error("Slack inbox idempotency conflict could not be resolved");
5561
+ return { inserted: false, entry: mapSlackInteractionInbox(existing) };
5562
+ },
5563
+ );
5564
+ }
5565
+
5566
+ export async function claimSlackInteractionInbox(
5567
+ db: Database,
5568
+ claimHolderId: string,
5569
+ claimLeaseMs: number,
5570
+ ): Promise<SlackInteractionInboxEntry | null> {
5571
+ const rows = await db.execute<typeof schema.slackInteractionInbox.$inferSelect>(
5572
+ sql`select * from opengeni_private.claim_slack_interaction_inbox(${claimHolderId}::uuid, ${claimLeaseMs})`,
5573
+ );
5574
+ return rows[0] ? mapSlackInteractionInbox(rows[0]) : null;
5575
+ }
5576
+
5577
+ export async function settleSlackInteractionInbox(
5578
+ db: Database,
5579
+ input: {
5580
+ entry: Pick<SlackInteractionInboxEntry, "id" | "accountId" | "workspaceId">;
5581
+ claimHolderId: string;
5582
+ outcome: "processed" | "failed";
5583
+ errorCode?: string | null;
5584
+ },
5585
+ ): Promise<boolean> {
5586
+ return await withRlsContext(db, input.entry, async (scopedDb) => {
5587
+ const rows = await scopedDb
5588
+ .update(schema.slackInteractionInbox)
5589
+ .set({
5590
+ status: input.outcome,
5591
+ claimHolderId: null,
5592
+ claimExpiresAt: null,
5593
+ retryAt: null,
5594
+ processedAt: sql`now()`,
5595
+ lastErrorCode: input.errorCode ?? null,
5596
+ updatedAt: sql`now()`,
5597
+ })
5598
+ .where(
5599
+ and(
5600
+ eq(schema.slackInteractionInbox.id, input.entry.id),
5601
+ eq(schema.slackInteractionInbox.claimHolderId, input.claimHolderId),
5602
+ eq(schema.slackInteractionInbox.status, "processing"),
5603
+ ),
5604
+ )
5605
+ .returning({ id: schema.slackInteractionInbox.id });
5606
+ return rows.length === 1;
5607
+ });
5608
+ }
5609
+
5610
+ export async function releaseSlackInteractionInbox(
5611
+ db: Database,
5612
+ input: {
5613
+ entry: Pick<SlackInteractionInboxEntry, "id" | "accountId" | "workspaceId">;
5614
+ claimHolderId: string;
5615
+ errorCode: string;
5616
+ retryAt: Date;
5617
+ },
5618
+ ): Promise<boolean> {
5619
+ return await withRlsContext(db, input.entry, async (scopedDb) => {
5620
+ const rows = await scopedDb
5621
+ .update(schema.slackInteractionInbox)
5622
+ .set({
5623
+ status: "pending",
5624
+ claimHolderId: null,
5625
+ claimExpiresAt: null,
5626
+ retryAt: input.retryAt,
5627
+ lastErrorCode: input.errorCode,
5628
+ updatedAt: sql`now()`,
5629
+ })
5630
+ .where(
5631
+ and(
5632
+ eq(schema.slackInteractionInbox.id, input.entry.id),
5633
+ eq(schema.slackInteractionInbox.claimHolderId, input.claimHolderId),
5634
+ eq(schema.slackInteractionInbox.status, "processing"),
5635
+ ),
5636
+ )
5637
+ .returning({ id: schema.slackInteractionInbox.id });
5638
+ return rows.length === 1;
5639
+ });
5640
+ }
5641
+
5642
+ export async function getOrCreateSlackInteraction(
5643
+ db: Database,
5644
+ input: Omit<
5645
+ SlackInteraction,
5646
+ | "id"
5647
+ | "sessionReservationId"
5648
+ | "sessionId"
5649
+ | "lastDeliveredSessionEventSequence"
5650
+ | "deliveryClaimHolderId"
5651
+ | "deliveryClaimExpiresAt"
5652
+ | "deliveryAttemptCount"
5653
+ | "deliveryRetryAt"
5654
+ | "deliveryLastErrorCode"
5655
+ | "ackSlackMessageTs"
5656
+ | "progressCount"
5657
+ | "terminalDeliveryState"
5658
+ | "createdAt"
5659
+ | "updatedAt"
5660
+ >,
5661
+ ): Promise<{ created: boolean; interaction: SlackInteraction }> {
5662
+ return await withRlsContext(db, input, async (scopedDb) => {
5663
+ const [created] = await scopedDb
5664
+ .insert(schema.slackInteractions)
5665
+ .values(input)
5666
+ .onConflictDoNothing()
5667
+ .returning();
5668
+ if (created) return { created: true, interaction: mapSlackInteraction(created) };
5669
+ const [existing] = await scopedDb
5670
+ .select()
5671
+ .from(schema.slackInteractions)
5672
+ .where(
5673
+ and(
5674
+ eq(schema.slackInteractions.connectionId, input.connectionId),
5675
+ eq(schema.slackInteractions.routeKey, input.routeKey),
5676
+ ),
5677
+ )
5678
+ .limit(1);
5679
+ if (!existing) throw new Error("Slack route idempotency conflict could not be resolved");
5680
+ return { created: false, interaction: mapSlackInteraction(existing) };
5681
+ });
5682
+ }
5683
+
5684
+ export async function getSlackInteractionByRoute(
5685
+ db: Database,
5686
+ workspaceId: string,
5687
+ connectionId: string,
5688
+ routeKey: string,
5689
+ ): Promise<SlackInteraction | null> {
5690
+ return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
5691
+ const [row] = await scopedDb
5692
+ .select()
5693
+ .from(schema.slackInteractions)
5694
+ .where(
5695
+ and(
5696
+ eq(schema.slackInteractions.workspaceId, workspaceId),
5697
+ eq(schema.slackInteractions.connectionId, connectionId),
5698
+ eq(schema.slackInteractions.routeKey, routeKey),
5699
+ ),
5700
+ )
5701
+ .limit(1);
5702
+ return row ? mapSlackInteraction(row) : null;
5703
+ });
5704
+ }
5705
+
5706
+ export async function getSlackInteractionSessionAccess(
5707
+ db: Database,
5708
+ workspaceId: string,
5709
+ rootSessionId: string,
5710
+ ): Promise<Pick<SlackInteraction, "owningSubjectId" | "visibility"> | null> {
5711
+ return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
5712
+ const [row] = await scopedDb
5713
+ .select({
5714
+ owningSubjectId: schema.slackInteractions.owningSubjectId,
5715
+ visibility: schema.slackInteractions.visibility,
5716
+ })
5717
+ .from(schema.slackInteractions)
5718
+ .where(
5719
+ and(
5720
+ eq(schema.slackInteractions.workspaceId, workspaceId),
5721
+ eq(schema.slackInteractions.sessionReservationId, rootSessionId),
5722
+ ),
5723
+ )
5724
+ .limit(1);
5725
+ return row ?? null;
5726
+ });
5727
+ }
5728
+
5729
+ export async function getSlackInteractionSessionAccessForSession(
5730
+ db: Database,
5731
+ input: {
5732
+ accountId: string;
5733
+ workspaceId: string;
5734
+ sessionId: string;
5735
+ },
5736
+ ): Promise<
5737
+ (Pick<SlackInteraction, "owningSubjectId" | "visibility"> & { rootSessionId: string }) | null
5738
+ > {
5739
+ return await withRlsContext(db, input, async (scopedDb) => {
5740
+ const rows = await scopedDb.execute<{
5741
+ rootSessionId: string;
5742
+ parentSessionId: string | null;
5743
+ depth: number;
5744
+ cycle: boolean;
5745
+ owningSubjectId: string | null;
5746
+ visibility: SlackInteraction["visibility"] | null;
5747
+ }>(sql`
5748
+ with recursive lineage(id, parent_session_id, depth, path, cycle) as (
5749
+ select
5750
+ ${schema.sessions.id},
5751
+ ${schema.sessions.parentSessionId},
5752
+ 0,
5753
+ array[${schema.sessions.id}],
5754
+ false
5755
+ from ${schema.sessions}
5756
+ where ${schema.sessions.accountId} = ${input.accountId}
5757
+ and ${schema.sessions.workspaceId} = ${input.workspaceId}
5758
+ and ${schema.sessions.id} = ${input.sessionId}
5759
+ union all
5760
+ select
5761
+ parent.id,
5762
+ parent.parent_session_id,
5763
+ lineage.depth + 1,
5764
+ lineage.path || parent.id,
5765
+ parent.id = any(lineage.path)
5766
+ from ${schema.sessions} parent
5767
+ join lineage on lineage.parent_session_id = parent.id
5768
+ where parent.account_id = ${input.accountId}
5769
+ and parent.workspace_id = ${input.workspaceId}
5770
+ and not lineage.cycle
5771
+ and lineage.depth < 64
5772
+ ), root as (
5773
+ select id, parent_session_id, depth, cycle
5774
+ from lineage
5775
+ order by depth desc
5776
+ limit 1
5777
+ )
5778
+ select
5779
+ root.id as "rootSessionId",
5780
+ root.parent_session_id as "parentSessionId",
5781
+ root.depth,
5782
+ root.cycle,
5783
+ interaction.owning_subject_id as "owningSubjectId",
5784
+ interaction.visibility
5785
+ from root
5786
+ left join ${schema.slackInteractions} interaction
5787
+ on interaction.account_id = ${input.accountId}
5788
+ and interaction.workspace_id = ${input.workspaceId}
5789
+ and interaction.session_reservation_id = root.id
5790
+ `);
5791
+ const root = rows[0];
5792
+ if (!root) return null;
5793
+ if (root.cycle || root.parentSessionId !== null || Number(root.depth) >= 64) {
5794
+ throw new Error(`session lineage for ${input.sessionId} has no valid workspace root`);
5795
+ }
5796
+ if (!root.owningSubjectId || !root.visibility) return null;
5797
+ return {
5798
+ rootSessionId: root.rootSessionId,
5799
+ owningSubjectId: root.owningSubjectId,
5800
+ visibility: root.visibility,
5801
+ };
5802
+ });
5803
+ }
5804
+
5805
+ export async function bindSlackInteractionSession(
5806
+ db: Database,
5807
+ input: Pick<SlackInteraction, "id" | "accountId" | "workspaceId" | "owningSubjectId"> & {
5808
+ sessionId: string;
5809
+ },
5810
+ ): Promise<SlackInteraction | null> {
5811
+ return await withRlsContext(db, input, async (scopedDb) => {
5812
+ const [row] = await scopedDb
5813
+ .update(schema.slackInteractions)
5814
+ .set({ sessionId: input.sessionId, updatedAt: sql`now()` })
5815
+ .where(
5816
+ and(
5817
+ eq(schema.slackInteractions.id, input.id),
5818
+ eq(schema.slackInteractions.owningSubjectId, input.owningSubjectId),
5819
+ eq(schema.slackInteractions.sessionReservationId, input.sessionId),
5820
+ isNull(schema.slackInteractions.sessionId),
5821
+ ),
5822
+ )
5823
+ .returning();
5824
+ if (row) return mapSlackInteraction(row);
5825
+ const [existing] = await scopedDb
5826
+ .select()
5827
+ .from(schema.slackInteractions)
5828
+ .where(eq(schema.slackInteractions.id, input.id))
5829
+ .limit(1);
5830
+ return existing?.sessionId === input.sessionId ? mapSlackInteraction(existing) : null;
5831
+ });
5832
+ }
5833
+
5834
+ export type SlackInteractionProgressClaim =
5835
+ | {
5836
+ kind: "claimed";
5837
+ created: boolean;
5838
+ progressCount: number;
5839
+ delivery: SlackInteractionProgressDelivery;
5840
+ }
5841
+ | { kind: "limit_reached"; progressCount: number }
5842
+ | { kind: "not_owned" };
5843
+
5844
+ /**
5845
+ * Reserve one globally bounded progress slot before any Slack provider call.
5846
+ * The interaction row lock serializes replicas and expired delivery claim
5847
+ * successors; the event row preserves one operation UUID across every retry.
5848
+ */
5849
+ export async function claimSlackInteractionProgressDelivery(
5850
+ db: Database,
5851
+ input: {
5852
+ accountId: string;
5853
+ workspaceId: string;
5854
+ interactionId: string;
5855
+ claimHolderId: string;
5856
+ sessionEventSequence: number;
5857
+ maxProgress: number;
5858
+ },
5859
+ ): Promise<SlackInteractionProgressClaim> {
5860
+ if (
5861
+ !Number.isSafeInteger(input.sessionEventSequence) ||
5862
+ input.sessionEventSequence < 1 ||
5863
+ !Number.isSafeInteger(input.maxProgress) ||
5864
+ input.maxProgress < 1 ||
5865
+ input.maxProgress > 3
5866
+ ) {
5867
+ throw new RangeError("invalid Slack progress delivery claim bounds");
5868
+ }
5869
+ return await withRlsContext(db, input, async (scopedDb) => {
5870
+ const [interaction] = await scopedDb
5871
+ .select({
5872
+ progressCount: schema.slackInteractions.progressCount,
5873
+ deliveryClaimHolderId: schema.slackInteractions.deliveryClaimHolderId,
5874
+ terminalDeliveryState: schema.slackInteractions.terminalDeliveryState,
5875
+ })
5876
+ .from(schema.slackInteractions)
5877
+ .where(
5878
+ and(
5879
+ eq(schema.slackInteractions.id, input.interactionId),
5880
+ eq(schema.slackInteractions.accountId, input.accountId),
5881
+ eq(schema.slackInteractions.workspaceId, input.workspaceId),
5882
+ ),
5883
+ )
5884
+ .for("update")
5885
+ .limit(1);
5886
+ if (
5887
+ !interaction ||
5888
+ interaction.deliveryClaimHolderId !== input.claimHolderId ||
5889
+ interaction.terminalDeliveryState !== "open"
5890
+ ) {
5891
+ return { kind: "not_owned" };
5892
+ }
5893
+
5894
+ const [existing] = await scopedDb
5895
+ .select()
5896
+ .from(schema.slackInteractionProgressDeliveries)
5897
+ .where(
5898
+ and(
5899
+ eq(schema.slackInteractionProgressDeliveries.interactionId, input.interactionId),
5900
+ eq(
5901
+ schema.slackInteractionProgressDeliveries.sessionEventSequence,
5902
+ input.sessionEventSequence,
5903
+ ),
5904
+ ),
5905
+ )
5906
+ .limit(1);
5907
+ if (existing) {
5908
+ return {
5909
+ kind: "claimed",
5910
+ created: false,
5911
+ progressCount: interaction.progressCount,
5912
+ delivery: mapSlackInteractionProgressDelivery(existing),
5913
+ };
5914
+ }
5915
+ if (interaction.progressCount >= input.maxProgress) {
5916
+ return { kind: "limit_reached", progressCount: interaction.progressCount };
5917
+ }
5918
+
5919
+ const slot = interaction.progressCount + 1;
5920
+ const [delivery] = await scopedDb
5921
+ .insert(schema.slackInteractionProgressDeliveries)
5922
+ .values({
5923
+ accountId: input.accountId,
5924
+ workspaceId: input.workspaceId,
5925
+ interactionId: input.interactionId,
5926
+ sessionEventSequence: input.sessionEventSequence,
5927
+ slot,
5928
+ operationId: crypto.randomUUID(),
5929
+ })
5930
+ .returning();
5931
+ if (!delivery) throw new Error("Slack progress delivery claim returned no row");
5932
+ const [updated] = await scopedDb
5933
+ .update(schema.slackInteractions)
5934
+ .set({ progressCount: slot, updatedAt: sql`now()` })
5935
+ .where(
5936
+ and(
5937
+ eq(schema.slackInteractions.id, input.interactionId),
5938
+ eq(schema.slackInteractions.deliveryClaimHolderId, input.claimHolderId),
5939
+ eq(schema.slackInteractions.progressCount, interaction.progressCount),
5940
+ ),
5941
+ )
5942
+ .returning({ progressCount: schema.slackInteractions.progressCount });
5943
+ if (!updated) throw new Error("Slack progress delivery lost its durable interaction claim");
5944
+ return {
5945
+ kind: "claimed",
5946
+ created: true,
5947
+ progressCount: updated.progressCount,
5948
+ delivery: mapSlackInteractionProgressDelivery(delivery),
5949
+ };
5950
+ });
5951
+ }
5952
+
5953
+ export async function rekeySlackInteractionRoute(
5954
+ db: Database,
5955
+ input: Pick<SlackInteraction, "id" | "accountId" | "workspaceId"> & {
5956
+ routeKey: string;
5957
+ slackThreadTs: string;
5958
+ ackSlackMessageTs: string;
5959
+ },
5960
+ ): Promise<SlackInteraction | null> {
5961
+ return await withRlsContext(db, input, async (scopedDb) => {
5962
+ const [row] = await scopedDb
5963
+ .update(schema.slackInteractions)
5964
+ .set({
5965
+ routeKey: input.routeKey,
5966
+ slackThreadTs: input.slackThreadTs,
5967
+ ackSlackMessageTs: input.ackSlackMessageTs,
5968
+ updatedAt: sql`now()`,
5969
+ })
5970
+ .where(eq(schema.slackInteractions.id, input.id))
5971
+ .returning();
5972
+ return row ? mapSlackInteraction(row) : null;
5973
+ });
5974
+ }
5975
+
5976
+ export async function claimSlackInteractionDelivery(
5977
+ db: Database,
5978
+ claimHolderId: string,
5979
+ claimLeaseMs: number,
5980
+ ): Promise<SlackInteraction | null> {
5981
+ const rows = await db.execute<typeof schema.slackInteractions.$inferSelect>(
5982
+ sql`select * from opengeni_private.claim_slack_interaction_delivery(${claimHolderId}::uuid, ${claimLeaseMs})`,
5983
+ );
5984
+ return rows[0] ? mapSlackInteraction(rows[0]) : null;
5985
+ }
5986
+
5987
+ export async function reopenSlackInteractionDelivery(
5988
+ db: Database,
5989
+ input: Pick<SlackInteraction, "id" | "accountId" | "workspaceId">,
5990
+ ): Promise<boolean> {
5991
+ return await withRlsContext(db, input, async (scopedDb) => {
5992
+ const rows = await scopedDb
5993
+ .update(schema.slackInteractions)
5994
+ .set({
5995
+ terminalDeliveryState: "open",
5996
+ deliveryAttemptCount: 0,
5997
+ deliveryRetryAt: null,
5998
+ deliveryLastErrorCode: null,
5999
+ updatedAt: sql`now()`,
6000
+ })
6001
+ .where(eq(schema.slackInteractions.id, input.id))
6002
+ .returning({ id: schema.slackInteractions.id });
6003
+ return rows.length === 1;
6004
+ });
6005
+ }
6006
+
6007
+ export async function advanceSlackInteractionDelivery(
6008
+ db: Database,
6009
+ input: Pick<SlackInteraction, "id" | "accountId" | "workspaceId"> & {
6010
+ claimHolderId: string;
6011
+ sequence: number;
6012
+ ackSlackMessageTs?: string | null;
6013
+ },
6014
+ ): Promise<boolean> {
6015
+ return await withRlsContext(db, input, async (scopedDb) => {
6016
+ const rows = await scopedDb
6017
+ .update(schema.slackInteractions)
6018
+ .set({
6019
+ lastDeliveredSessionEventSequence: input.sequence,
6020
+ ...(input.ackSlackMessageTs !== undefined
6021
+ ? { ackSlackMessageTs: input.ackSlackMessageTs }
6022
+ : {}),
6023
+ deliveryAttemptCount: 0,
6024
+ deliveryRetryAt: null,
6025
+ deliveryLastErrorCode: null,
6026
+ updatedAt: sql`now()`,
6027
+ })
6028
+ .where(
6029
+ and(
6030
+ eq(schema.slackInteractions.id, input.id),
6031
+ eq(schema.slackInteractions.deliveryClaimHolderId, input.claimHolderId),
6032
+ lte(schema.slackInteractions.lastDeliveredSessionEventSequence, input.sequence),
6033
+ ),
6034
+ )
6035
+ .returning({ id: schema.slackInteractions.id });
6036
+ return rows.length === 1;
6037
+ });
6038
+ }
6039
+
6040
+ export async function releaseSlackInteractionDelivery(
6041
+ db: Database,
6042
+ input: Pick<SlackInteraction, "id" | "accountId" | "workspaceId"> & {
6043
+ claimHolderId: string;
6044
+ },
6045
+ ): Promise<boolean> {
6046
+ return await withRlsContext(db, input, async (scopedDb) => {
6047
+ const rows = await scopedDb
6048
+ .update(schema.slackInteractions)
6049
+ .set({
6050
+ deliveryClaimHolderId: null,
6051
+ deliveryClaimExpiresAt: null,
6052
+ updatedAt: sql`now()`,
6053
+ })
6054
+ .where(
6055
+ and(
6056
+ eq(schema.slackInteractions.id, input.id),
6057
+ eq(schema.slackInteractions.deliveryClaimHolderId, input.claimHolderId),
6058
+ ),
6059
+ )
6060
+ .returning({ id: schema.slackInteractions.id });
6061
+ return rows.length === 1;
6062
+ });
6063
+ }
6064
+
6065
+ export async function deferSlackInteractionDelivery(
6066
+ db: Database,
6067
+ input: Pick<SlackInteraction, "id" | "accountId" | "workspaceId"> & {
6068
+ claimHolderId: string;
6069
+ retryAt: Date;
6070
+ errorCode: string;
6071
+ },
6072
+ ): Promise<boolean> {
6073
+ return await withRlsContext(db, input, async (scopedDb) => {
6074
+ const rows = await scopedDb
6075
+ .update(schema.slackInteractions)
6076
+ .set({
6077
+ deliveryClaimHolderId: null,
6078
+ deliveryClaimExpiresAt: null,
6079
+ deliveryRetryAt: input.retryAt,
6080
+ deliveryLastErrorCode: input.errorCode.slice(0, 128),
6081
+ updatedAt: sql`now()`,
6082
+ })
6083
+ .where(
6084
+ and(
6085
+ eq(schema.slackInteractions.id, input.id),
6086
+ eq(schema.slackInteractions.deliveryClaimHolderId, input.claimHolderId),
6087
+ ),
6088
+ )
6089
+ .returning({ id: schema.slackInteractions.id });
6090
+ return rows.length === 1;
6091
+ });
6092
+ }
6093
+
6094
+ export async function closeSlackInteractionDelivery(
6095
+ db: Database,
6096
+ input: Pick<SlackInteraction, "id" | "accountId" | "workspaceId"> & {
6097
+ claimHolderId: string;
6098
+ sequence: number;
6099
+ state: Exclude<SlackInteraction["terminalDeliveryState"], "open">;
6100
+ errorCode?: string | null;
6101
+ },
6102
+ ): Promise<boolean> {
6103
+ return await withRlsContext(db, input, async (scopedDb) => {
6104
+ const rows = await scopedDb
6105
+ .update(schema.slackInteractions)
6106
+ .set({
6107
+ lastDeliveredSessionEventSequence: input.sequence,
6108
+ terminalDeliveryState: input.state,
6109
+ deliveryClaimHolderId: null,
6110
+ deliveryClaimExpiresAt: null,
6111
+ deliveryRetryAt: null,
6112
+ deliveryLastErrorCode: input.errorCode?.slice(0, 128) ?? null,
6113
+ updatedAt: sql`now()`,
6114
+ })
6115
+ .where(
6116
+ and(
6117
+ eq(schema.slackInteractions.id, input.id),
6118
+ eq(schema.slackInteractions.deliveryClaimHolderId, input.claimHolderId),
6119
+ ),
6120
+ )
6121
+ .returning({ id: schema.slackInteractions.id });
6122
+ return rows.length === 1;
6123
+ });
6124
+ }
6125
+
6126
+ function mapSlackBotUserLink(row: typeof schema.slackBotUserLinks.$inferSelect): SlackBotUserLink {
6127
+ return row;
6128
+ }
6129
+
6130
+ function mapSlackInteractionInbox(
6131
+ row: typeof schema.slackInteractionInbox.$inferSelect | Record<string, unknown>,
6132
+ ): SlackInteractionInboxEntry {
6133
+ return {
6134
+ id: slackRowString(row, "id", "id"),
6135
+ accountId: slackRowString(row, "accountId", "account_id"),
6136
+ workspaceId: slackRowString(row, "workspaceId", "workspace_id"),
6137
+ connectionId: slackRowString(row, "connectionId", "connection_id"),
6138
+ providerEventId: slackRowString(row, "providerEventId", "provider_event_id"),
6139
+ providerMessageId: slackRowString(row, "providerMessageId", "provider_message_id"),
6140
+ slackTeamId: slackRowString(row, "slackTeamId", "slack_team_id"),
6141
+ slackUserId: slackRowString(row, "slackUserId", "slack_user_id"),
6142
+ slackChannelId: slackRowString(row, "slackChannelId", "slack_channel_id"),
6143
+ slackMessageTs: slackRowString(row, "slackMessageTs", "slack_message_ts"),
6144
+ slackThreadTs: slackRowNullableString(row, "slackThreadTs", "slack_thread_ts"),
6145
+ triggerKind: slackRowString(row, "triggerKind", "trigger_kind") as SlackInteractionTriggerKind,
6146
+ text: slackRowString(row, "text", "text"),
6147
+ status: slackRowString(row, "status", "status") as SlackInteractionInboxEntry["status"],
6148
+ claimHolderId: slackRowNullableString(row, "claimHolderId", "claim_holder_id"),
6149
+ claimExpiresAt: slackRowNullableDate(row, "claimExpiresAt", "claim_expires_at"),
6150
+ attemptCount: slackRowNumber(row, "attemptCount", "attempt_count"),
6151
+ retryAt: slackRowNullableDate(row, "retryAt", "retry_at"),
6152
+ lastErrorCode: slackRowNullableString(row, "lastErrorCode", "last_error_code"),
6153
+ processedAt: slackRowNullableDate(row, "processedAt", "processed_at"),
6154
+ createdAt: slackRowDate(row, "createdAt", "created_at"),
6155
+ updatedAt: slackRowDate(row, "updatedAt", "updated_at"),
6156
+ };
6157
+ }
6158
+
6159
+ function mapSlackInteraction(
6160
+ row: typeof schema.slackInteractions.$inferSelect | Record<string, unknown>,
6161
+ ): SlackInteraction {
6162
+ return {
6163
+ id: slackRowString(row, "id", "id"),
6164
+ accountId: slackRowString(row, "accountId", "account_id"),
6165
+ workspaceId: slackRowString(row, "workspaceId", "workspace_id"),
6166
+ connectionId: slackRowString(row, "connectionId", "connection_id"),
6167
+ slackTeamId: slackRowString(row, "slackTeamId", "slack_team_id"),
6168
+ slackChannelId: slackRowString(row, "slackChannelId", "slack_channel_id"),
6169
+ slackThreadTs: slackRowString(row, "slackThreadTs", "slack_thread_ts"),
6170
+ routeKey: slackRowString(row, "routeKey", "route_key"),
6171
+ triggeringProviderEventId: slackRowString(
6172
+ row,
6173
+ "triggeringProviderEventId",
6174
+ "triggering_provider_event_id",
6175
+ ),
6176
+ owningSubjectId: slackRowString(row, "owningSubjectId", "owning_subject_id"),
6177
+ visibility: slackRowString(row, "visibility", "visibility") as SlackInteraction["visibility"],
6178
+ sessionReservationId: slackRowString(row, "sessionReservationId", "session_reservation_id"),
6179
+ sessionId: slackRowNullableString(row, "sessionId", "session_id"),
6180
+ lastDeliveredSessionEventSequence: slackRowNumber(
6181
+ row,
6182
+ "lastDeliveredSessionEventSequence",
6183
+ "last_delivered_session_event_sequence",
6184
+ ),
6185
+ deliveryClaimHolderId: slackRowNullableString(
6186
+ row,
6187
+ "deliveryClaimHolderId",
6188
+ "delivery_claim_holder_id",
6189
+ ),
6190
+ deliveryClaimExpiresAt: slackRowNullableDate(
6191
+ row,
6192
+ "deliveryClaimExpiresAt",
6193
+ "delivery_claim_expires_at",
6194
+ ),
6195
+ deliveryAttemptCount: slackRowNumber(row, "deliveryAttemptCount", "delivery_attempt_count"),
6196
+ deliveryRetryAt: slackRowNullableDate(row, "deliveryRetryAt", "delivery_retry_at"),
6197
+ deliveryLastErrorCode: slackRowNullableString(
6198
+ row,
6199
+ "deliveryLastErrorCode",
6200
+ "delivery_last_error_code",
6201
+ ),
6202
+ ackSlackMessageTs: slackRowNullableString(row, "ackSlackMessageTs", "ack_slack_message_ts"),
6203
+ progressCount: slackRowNumber(row, "progressCount", "progress_count"),
6204
+ terminalDeliveryState: slackRowString(
6205
+ row,
6206
+ "terminalDeliveryState",
6207
+ "terminal_delivery_state",
6208
+ ) as SlackInteraction["terminalDeliveryState"],
6209
+ createdAt: slackRowDate(row, "createdAt", "created_at"),
6210
+ updatedAt: slackRowDate(row, "updatedAt", "updated_at"),
6211
+ };
6212
+ }
6213
+
6214
+ function mapSlackInteractionProgressDelivery(
6215
+ row: typeof schema.slackInteractionProgressDeliveries.$inferSelect | Record<string, unknown>,
6216
+ ): SlackInteractionProgressDelivery {
6217
+ return {
6218
+ id: slackRowString(row, "id", "id"),
6219
+ accountId: slackRowString(row, "accountId", "account_id"),
6220
+ workspaceId: slackRowString(row, "workspaceId", "workspace_id"),
6221
+ interactionId: slackRowString(row, "interactionId", "interaction_id"),
6222
+ sessionEventSequence: slackRowNumber(row, "sessionEventSequence", "session_event_sequence"),
6223
+ slot: slackRowNumber(row, "slot", "slot"),
6224
+ operationId: slackRowString(row, "operationId", "operation_id"),
6225
+ createdAt: slackRowDate(row, "createdAt", "created_at"),
6226
+ };
6227
+ }
6228
+
6229
+ function slackRowValue(row: Record<string, unknown>, camelKey: string, snakeKey: string): unknown {
6230
+ return row[camelKey] ?? row[snakeKey];
6231
+ }
6232
+
6233
+ function slackRowString(row: Record<string, unknown>, camelKey: string, snakeKey: string): string {
6234
+ const value = slackRowValue(row, camelKey, snakeKey);
6235
+ if (typeof value !== "string") throw new Error(`Slack row omitted ${snakeKey}`);
6236
+ return value;
6237
+ }
6238
+
6239
+ function slackRowNullableString(
6240
+ row: Record<string, unknown>,
6241
+ camelKey: string,
6242
+ snakeKey: string,
6243
+ ): string | null {
6244
+ const value = slackRowValue(row, camelKey, snakeKey);
6245
+ if (value === null || value === undefined) return null;
6246
+ if (typeof value !== "string") throw new Error(`Slack row malformed ${snakeKey}`);
6247
+ return value;
6248
+ }
6249
+
6250
+ function slackRowNumber(row: Record<string, unknown>, camelKey: string, snakeKey: string): number {
6251
+ const value = slackRowValue(row, camelKey, snakeKey);
6252
+ const parsed = typeof value === "number" ? value : Number(value);
6253
+ if (!Number.isSafeInteger(parsed)) throw new Error(`Slack row malformed ${snakeKey}`);
6254
+ return parsed;
6255
+ }
6256
+
6257
+ function slackRowDate(row: Record<string, unknown>, camelKey: string, snakeKey: string): Date {
6258
+ const value = slackRowValue(row, camelKey, snakeKey);
6259
+ const parsed = value instanceof Date ? value : new Date(String(value));
6260
+ if (Number.isNaN(parsed.getTime())) throw new Error(`Slack row malformed ${snakeKey}`);
6261
+ return parsed;
6262
+ }
6263
+
6264
+ function slackRowNullableDate(
6265
+ row: Record<string, unknown>,
6266
+ camelKey: string,
6267
+ snakeKey: string,
6268
+ ): Date | null {
6269
+ const value = slackRowValue(row, camelKey, snakeKey);
6270
+ if (value === null || value === undefined) return null;
6271
+ return slackRowDate(row, camelKey, snakeKey);
6272
+ }
6273
+
5300
6274
  export class SlackBotLifecycleSuccessAuditError extends Error {
5301
6275
  constructor() {
5302
6276
  super("OpenGeni Slack bot lifecycle success audit failed");
@@ -5889,7 +6863,10 @@ export async function claimSlackBotDeleteOperation(
5889
6863
  })
5890
6864
  .returning();
5891
6865
  if (created) {
5892
- return { kind: "claimed", operation: mapSlackBotDeleteOperation(created) } as const;
6866
+ return {
6867
+ kind: "claimed",
6868
+ operation: mapSlackBotDeleteOperation(created),
6869
+ } as const;
5893
6870
  }
5894
6871
 
5895
6872
  const [existing] = await tx
@@ -6030,7 +7007,11 @@ export async function releaseSlackBotDeleteOperationClaim(
6030
7007
  }
6031
7008
 
6032
7009
  export type CompleteSlackBotDeleteOperationResult =
6033
- | { kind: "completed"; operation: SlackBotDeleteOperation; newlyCompleted: boolean }
7010
+ | {
7011
+ kind: "completed";
7012
+ operation: SlackBotDeleteOperation;
7013
+ newlyCompleted: boolean;
7014
+ }
6034
7015
  | { kind: "not_found" | "not_owned" };
6035
7016
 
6036
7017
  export async function completeSlackBotDeleteOperation(
@@ -6174,13 +7155,25 @@ export async function loadConnectionCredentialForBroker(
6174
7155
  async (scopedDb) => {
6175
7156
  // Prefer active rows: a revoke bumps updatedAt, so recency alone would let a
6176
7157
  // freshly revoked connection shadow an active replacement for the provider.
7158
+ // UUID-free Personal Slack lookup additionally mirrors the UI/reconnect
7159
+ // canonical rule through createdAt and immutable UUID tie-breakers. Migration
7160
+ // 0132 can stamp legacy duplicates with the same updatedAt in one transaction.
7161
+ const personalSlackSubjectLookup =
7162
+ !input.connectionId &&
7163
+ input.allowSubjectOwned === true &&
7164
+ input.providerDomain === "slack.com" &&
7165
+ input.kind === "oauth2";
6177
7166
  const [row] = await scopedDb
6178
7167
  .select()
6179
7168
  .from(schema.connections)
6180
7169
  .where(and(...conditions))
6181
7170
  .orderBy(
6182
- desc(sql`(${schema.connections.status} = 'active')`),
6183
- desc(schema.connections.updatedAt),
7171
+ ...(personalSlackSubjectLookup
7172
+ ? personalSlackCanonicalConnectionOrder()
7173
+ : [
7174
+ desc(sql`(${schema.connections.status} = 'active')`),
7175
+ desc(schema.connections.updatedAt),
7176
+ ]),
6184
7177
  )
6185
7178
  .limit(1);
6186
7179
  if (!row) {
@@ -7710,6 +8703,7 @@ export async function createSocialConnection(
7710
8703
  status: input.status,
7711
8704
  scopes: input.scopes ?? [],
7712
8705
  credentialRef: input.credentialRef ?? null,
8706
+ credentialEncrypted: input.credentialEncrypted ?? null,
7713
8707
  tokenMetadata: input.tokenMetadata ?? {},
7714
8708
  metadata: input.metadata ?? {},
7715
8709
  })
@@ -7722,6 +8716,118 @@ export async function createSocialConnection(
7722
8716
  );
7723
8717
  }
7724
8718
 
8719
+ /**
8720
+ * OAuth-callback persistence: reconnecting the same provider account replaces
8721
+ * the stored credential and revives the connection instead of failing on the
8722
+ * (workspace, provider, handle) unique index.
8723
+ */
8724
+ export async function upsertSocialOAuthConnection(
8725
+ db: Database,
8726
+ input: UpsertSocialOAuthConnectionInput,
8727
+ ): Promise<SocialConnection> {
8728
+ return await withRlsContext(
8729
+ db,
8730
+ { accountId: input.accountId, workspaceId: input.workspaceId },
8731
+ async (scopedDb) => {
8732
+ const [row] = await scopedDb
8733
+ .insert(schema.socialConnections)
8734
+ .values({
8735
+ accountId: input.accountId,
8736
+ workspaceId: input.workspaceId,
8737
+ provider: input.provider,
8738
+ accountHandle: input.accountHandle,
8739
+ accountName: input.accountName ?? null,
8740
+ externalAccountId: input.externalAccountId ?? null,
8741
+ status: "connected",
8742
+ scopes: input.scopes,
8743
+ credentialEncrypted: input.credentialEncrypted,
8744
+ tokenMetadata: input.tokenMetadata ?? {},
8745
+ })
8746
+ .onConflictDoUpdate({
8747
+ target: [
8748
+ schema.socialConnections.workspaceId,
8749
+ schema.socialConnections.provider,
8750
+ schema.socialConnections.accountHandle,
8751
+ ],
8752
+ set: {
8753
+ accountName: input.accountName ?? null,
8754
+ externalAccountId: input.externalAccountId ?? null,
8755
+ status: "connected",
8756
+ scopes: input.scopes,
8757
+ credentialEncrypted: input.credentialEncrypted,
8758
+ tokenMetadata: input.tokenMetadata ?? {},
8759
+ updatedAt: new Date(),
8760
+ },
8761
+ })
8762
+ .returning();
8763
+ if (!row) {
8764
+ throw new Error("Failed to upsert social connection");
8765
+ }
8766
+ return mapSocialConnection(row);
8767
+ },
8768
+ );
8769
+ }
8770
+
8771
+ /**
8772
+ * Host-side credential read for the social API client. Deliberately not part
8773
+ * of mapSocialConnection so the encrypted bundle never rides along on list or
8774
+ * MCP responses.
8775
+ */
8776
+ export async function loadSocialConnectionCredential(
8777
+ db: Database,
8778
+ workspaceId: string,
8779
+ connectionId: string,
8780
+ ): Promise<{ connection: SocialConnection; credentialEncrypted: string | null } | null> {
8781
+ return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
8782
+ const [row] = await scopedDb
8783
+ .select()
8784
+ .from(schema.socialConnections)
8785
+ .where(
8786
+ and(
8787
+ eq(schema.socialConnections.workspaceId, workspaceId),
8788
+ eq(schema.socialConnections.id, connectionId),
8789
+ ),
8790
+ )
8791
+ .limit(1);
8792
+ if (!row) {
8793
+ return null;
8794
+ }
8795
+ return { connection: mapSocialConnection(row), credentialEncrypted: row.credentialEncrypted };
8796
+ });
8797
+ }
8798
+
8799
+ export async function updateSocialConnectionCredential(
8800
+ db: Database,
8801
+ input: UpdateSocialConnectionCredentialInput,
8802
+ ): Promise<SocialConnection | null> {
8803
+ return await withWorkspaceRls(db, input.workspaceId, async (scopedDb) => {
8804
+ const [row] = await scopedDb
8805
+ .update(schema.socialConnections)
8806
+ .set({
8807
+ ...(input.credentialEncrypted !== undefined
8808
+ ? { credentialEncrypted: input.credentialEncrypted }
8809
+ : {}),
8810
+ ...(input.status !== undefined ? { status: input.status } : {}),
8811
+ ...(input.tokenMetadata !== undefined ? { tokenMetadata: input.tokenMetadata } : {}),
8812
+ updatedAt: new Date(),
8813
+ })
8814
+ .where(
8815
+ and(
8816
+ eq(schema.socialConnections.workspaceId, input.workspaceId),
8817
+ eq(schema.socialConnections.id, input.connectionId),
8818
+ ),
8819
+ )
8820
+ .returning();
8821
+ return row ? mapSocialConnection(row) : null;
8822
+ });
8823
+ }
8824
+
8825
+ // List/get never select credential_encrypted (same posture as the broker
8826
+ // `connections` helpers): the ciphertext must not ride into API-process memory
8827
+ // on every list, where one added debug log or row spread would expose it.
8828
+ const { credentialEncrypted: _socialCredentialColumn, ...socialConnectionPublicColumns } =
8829
+ getTableColumns(schema.socialConnections);
8830
+
7725
8831
  export async function listSocialConnections(
7726
8832
  db: Database,
7727
8833
  workspaceId: string,
@@ -7729,7 +8835,7 @@ export async function listSocialConnections(
7729
8835
  ): Promise<SocialConnection[]> {
7730
8836
  return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
7731
8837
  const rows = await scopedDb
7732
- .select()
8838
+ .select(socialConnectionPublicColumns)
7733
8839
  .from(schema.socialConnections)
7734
8840
  .where(eq(schema.socialConnections.workspaceId, workspaceId))
7735
8841
  .orderBy(desc(schema.socialConnections.createdAt))
@@ -7745,7 +8851,7 @@ export async function getSocialConnection(
7745
8851
  ): Promise<SocialConnection | null> {
7746
8852
  return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
7747
8853
  const [row] = await scopedDb
7748
- .select()
8854
+ .select(socialConnectionPublicColumns)
7749
8855
  .from(schema.socialConnections)
7750
8856
  .where(
7751
8857
  and(
@@ -7807,6 +8913,70 @@ export async function createSocialPost(
7807
8913
  );
7808
8914
  }
7809
8915
 
8916
+ /**
8917
+ * Idempotent bulk ingest for provider sync: rows already present under the
8918
+ * (workspace, connection, external post id) unique index are skipped, so a
8919
+ * scheduled re-sync never duplicates or fails.
8920
+ */
8921
+ export async function recordSyncedSocialPosts(
8922
+ db: Database,
8923
+ input: {
8924
+ accountId: string;
8925
+ workspaceId: string;
8926
+ connectionId: string;
8927
+ posts: Array<{
8928
+ externalPostId: string;
8929
+ url?: string | null;
8930
+ authorHandle?: string | null;
8931
+ text: string;
8932
+ publishedAt: Date;
8933
+ metrics?: Record<string, number>;
8934
+ raw?: Record<string, unknown>;
8935
+ }>;
8936
+ },
8937
+ ): Promise<{ inserted: number; skipped: number }> {
8938
+ if (input.posts.length === 0) {
8939
+ return { inserted: 0, skipped: 0 };
8940
+ }
8941
+ return await withRlsContext(
8942
+ db,
8943
+ { accountId: input.accountId, workspaceId: input.workspaceId },
8944
+ async (scopedDb) => {
8945
+ const connection = await requireSocialConnection(
8946
+ scopedDb,
8947
+ input.workspaceId,
8948
+ input.connectionId,
8949
+ );
8950
+ const rows = await scopedDb
8951
+ .insert(schema.socialPosts)
8952
+ .values(
8953
+ input.posts.map((post) => ({
8954
+ accountId: input.accountId,
8955
+ workspaceId: input.workspaceId,
8956
+ connectionId: input.connectionId,
8957
+ provider: connection.provider,
8958
+ externalPostId: post.externalPostId,
8959
+ url: post.url ?? null,
8960
+ authorHandle: post.authorHandle ?? connection.accountHandle,
8961
+ text: post.text,
8962
+ publishedAt: post.publishedAt,
8963
+ metrics: post.metrics ?? {},
8964
+ raw: post.raw ?? {},
8965
+ })),
8966
+ )
8967
+ .onConflictDoNothing({
8968
+ target: [
8969
+ schema.socialPosts.workspaceId,
8970
+ schema.socialPosts.connectionId,
8971
+ schema.socialPosts.externalPostId,
8972
+ ],
8973
+ })
8974
+ .returning({ id: schema.socialPosts.id });
8975
+ return { inserted: rows.length, skipped: input.posts.length - rows.length };
8976
+ },
8977
+ );
8978
+ }
8979
+
7810
8980
  export async function listSocialPosts(
7811
8981
  db: Database,
7812
8982
  options: {
@@ -15680,9 +16850,21 @@ export async function sessionTreeStatsForSessions(
15680
16850
  }
15681
16851
 
15682
16852
  function sessionFilters(
15683
- options: Pick<ListSessionsForSubjectOptions, "authorizationScope" | "parentSessionId" | "search">,
16853
+ options: Pick<
16854
+ ListSessionsForSubjectOptions,
16855
+ "authorizationScope" | "parentSessionId" | "search" | "subjectId"
16856
+ >,
15684
16857
  ): SQL[] {
15685
- const filters: SQL[] = [];
16858
+ const filters: SQL[] = [
16859
+ sql`not exists (
16860
+ select 1
16861
+ from ${schema.slackInteractions} private_slack_interaction
16862
+ where private_slack_interaction.workspace_id = ${schema.sessions.workspaceId}
16863
+ and private_slack_interaction.session_reservation_id = ${schema.sessions.rootSessionId}
16864
+ and private_slack_interaction.visibility = 'private'
16865
+ and private_slack_interaction.owning_subject_id <> ${options.subjectId}
16866
+ )`,
16867
+ ];
15686
16868
  if (options.authorizationScope) {
15687
16869
  filters.push(sessionAuthorizationScopeFilter(options.authorizationScope));
15688
16870
  }
@@ -16295,7 +17477,20 @@ export async function getSessionForSubject(
16295
17477
  eq(schema.sessionPins.sessionId, schema.sessions.id),
16296
17478
  ),
16297
17479
  )
16298
- .where(and(eq(schema.sessions.workspaceId, workspaceId), eq(schema.sessions.id, sessionId)))
17480
+ .where(
17481
+ and(
17482
+ eq(schema.sessions.workspaceId, workspaceId),
17483
+ eq(schema.sessions.id, sessionId),
17484
+ sql`not exists (
17485
+ select 1
17486
+ from ${schema.slackInteractions} private_slack_interaction
17487
+ where private_slack_interaction.workspace_id = ${schema.sessions.workspaceId}
17488
+ and private_slack_interaction.session_reservation_id = ${schema.sessions.rootSessionId}
17489
+ and private_slack_interaction.visibility = 'private'
17490
+ and private_slack_interaction.owning_subject_id <> ${subjectId}
17491
+ )`,
17492
+ ),
17493
+ )
16299
17494
  .limit(1);
16300
17495
  if (!row) return null;
16301
17496
  const mcpServers = await sessionMcpServerMetadataForSessions(scopedDb, workspaceId, [
@@ -19436,7 +20631,10 @@ export async function recordSkippedContextCompaction(
19436
20631
  });
19437
20632
  if (!fence.allowed) return { recorded: false as const, reason: fence.reason };
19438
20633
  if (requirePendingRequest && !fence.session.compactRequested) {
19439
- return { recorded: false as const, reason: "request_not_pending" as const };
20634
+ return {
20635
+ recorded: false as const,
20636
+ reason: "request_not_pending" as const,
20637
+ };
19440
20638
  }
19441
20639
  const inserted = await tx
19442
20640
  .insert(schema.sessionEvents)
@@ -27566,7 +28764,11 @@ export type WorkspaceArchiveCaptureClaim = {
27566
28764
  };
27567
28765
 
27568
28766
  export type ClaimWorkspaceArchiveCaptureResult =
27569
- | { status: "claimed"; claim: WorkspaceArchiveCaptureClaim; lease: LeaseSnapshot }
28767
+ | {
28768
+ status: "claimed";
28769
+ claim: WorkspaceArchiveCaptureClaim;
28770
+ lease: LeaseSnapshot;
28771
+ }
27570
28772
  | {
27571
28773
  status:
27572
28774
  | "lease_fenced"
@@ -41223,7 +42425,9 @@ function mapKnowledgeMemory(row: typeof schema.knowledgeMemories.$inferSelect):
41223
42425
  };
41224
42426
  }
41225
42427
 
41226
- function mapSocialConnection(row: typeof schema.socialConnections.$inferSelect): SocialConnection {
42428
+ function mapSocialConnection(
42429
+ row: Omit<typeof schema.socialConnections.$inferSelect, "credentialEncrypted">,
42430
+ ): SocialConnection {
41227
42431
  return {
41228
42432
  id: row.id,
41229
42433
  accountId: row.accountId,
@@ -41457,3 +42661,4 @@ function shortHash(value: string): string {
41457
42661
  // evaluates under the index↔resolver module cycle.
41458
42662
  export * from "./codex-token-resolver";
41459
42663
  export * from "./connection-token-resolver";
42664
+ export * from "./workspace-artifacts";