@opengeni/db 4.4.1 → 4.5.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.
Files changed (82) hide show
  1. package/dist/canonical-human-identities.js +3 -3
  2. package/dist/capability-reconciliation.js +2 -2
  3. package/dist/{chunk-T7GDRZOH.js → chunk-4LVV4R4F.js} +2 -2
  4. package/dist/{chunk-NQWVBQFK.js → chunk-575FUJE2.js} +3 -3
  5. package/dist/{chunk-K2XOMTAG.js → chunk-7LFHUC35.js} +2 -2
  6. package/dist/{chunk-JC43DLHB.js → chunk-7P67EF5K.js} +5 -5
  7. package/dist/{chunk-XWANFAN4.js → chunk-BH2HBICR.js} +4 -4
  8. package/dist/{chunk-PB73JQOI.js → chunk-C3FI3DJ5.js} +2 -2
  9. package/dist/{chunk-LLQLCFCT.js → chunk-GSZZDYPV.js} +2 -2
  10. package/dist/{chunk-B4DWUU4Y.js → chunk-GYAQYECK.js} +2 -2
  11. package/dist/{chunk-GYHXV2LR.js → chunk-K2YN562I.js} +2 -2
  12. package/dist/{chunk-6UJX2OXO.js → chunk-KMBU4KDY.js} +25 -1
  13. package/dist/chunk-KMBU4KDY.js.map +1 -0
  14. package/dist/{chunk-JN2DIKGW.js → chunk-M6TNKUGE.js} +3 -1
  15. package/dist/chunk-M6TNKUGE.js.map +1 -0
  16. package/dist/{chunk-SSCUP23X.js → chunk-MSAWMGWJ.js} +6 -6
  17. package/dist/{chunk-T5RZ3HWS.js → chunk-OFQDECMZ.js} +4 -4
  18. package/dist/{chunk-7UA4WUGK.js → chunk-RRZO5XRA.js} +2671 -2335
  19. package/dist/{chunk-7UA4WUGK.js.map → chunk-RRZO5XRA.js.map} +1 -1
  20. package/dist/{chunk-72JNABPO.js → chunk-TIF52FMJ.js} +3 -3
  21. package/dist/{chunk-67VNEM5N.js → chunk-TLCTS7CZ.js} +4 -4
  22. package/dist/{chunk-VFQI3CDL.js → chunk-VXCIFQX7.js} +2 -2
  23. package/dist/{chunk-5H6FHFHD.js → chunk-XX5M3TJJ.js} +2 -2
  24. package/dist/{chunk-LUO43IZE.js → chunk-YOFYG5ZI.js} +2 -2
  25. package/dist/connect-attempts.js +3 -3
  26. package/dist/editable-artifact-durable-export.js +2 -2
  27. package/dist/editable-artifacts.js +3 -3
  28. package/dist/index.d.ts +21 -0
  29. package/dist/index.js +25 -19
  30. package/dist/managed-auth-session-sets.js +3 -3
  31. package/dist/mcp-operations.js +2 -2
  32. package/dist/migrate.js +19 -19
  33. package/dist/organization-integration-policy.js +3 -3
  34. package/dist/provision-roles.js +1 -1
  35. package/dist/retained-provider-commands.js +6 -6
  36. package/dist/runtime-posture.d.ts +2 -2
  37. package/dist/schema.d.ts +93 -0
  38. package/dist/schema.js +3 -1
  39. package/dist/session-background-commands.js +5 -5
  40. package/dist/session-command-output.js +5 -5
  41. package/dist/session-event-slices.js +3 -3
  42. package/dist/session-history-tool-identities.d.ts +11 -0
  43. package/dist/session-mcp-credential-rotation.js +6 -6
  44. package/dist/session-message-search.d.ts +21 -0
  45. package/dist/session-tenancy.js +6 -6
  46. package/dist/{subscription-core-codex-SUO2TISD.js → subscription-core-codex-L7NPFWAH.js} +6 -6
  47. package/dist/{subscription-core-codex-compat-SDHM4XG4.js → subscription-core-codex-compat-AK2UMLE3.js} +8 -8
  48. package/dist/subscription-usage-pool.d.ts +1 -1
  49. package/dist/video-generation.js +3 -3
  50. package/dist/workspace-artifacts.d.ts +13 -0
  51. package/dist/workspace-tool-defaults.js +5 -5
  52. package/drizzle/0907_subscription_connection_one_owner.sql +339 -209
  53. package/drizzle/0908_site_opens.sql +33 -0
  54. package/package.json +9 -9
  55. package/src/index.ts +304 -44
  56. package/src/runtime-posture.ts +2 -0
  57. package/src/schema.ts +24 -0
  58. package/src/session-history-tool-identities.ts +55 -0
  59. package/src/session-message-search.ts +188 -33
  60. package/src/subscription-core/administered-connections.ts +7 -5
  61. package/src/subscription-usage-pool.ts +7 -5
  62. package/src/workspace-artifacts.ts +45 -1
  63. package/dist/chunk-6UJX2OXO.js.map +0 -1
  64. package/dist/chunk-JN2DIKGW.js.map +0 -1
  65. /package/dist/{chunk-T7GDRZOH.js.map → chunk-4LVV4R4F.js.map} +0 -0
  66. /package/dist/{chunk-NQWVBQFK.js.map → chunk-575FUJE2.js.map} +0 -0
  67. /package/dist/{chunk-K2XOMTAG.js.map → chunk-7LFHUC35.js.map} +0 -0
  68. /package/dist/{chunk-JC43DLHB.js.map → chunk-7P67EF5K.js.map} +0 -0
  69. /package/dist/{chunk-XWANFAN4.js.map → chunk-BH2HBICR.js.map} +0 -0
  70. /package/dist/{chunk-PB73JQOI.js.map → chunk-C3FI3DJ5.js.map} +0 -0
  71. /package/dist/{chunk-LLQLCFCT.js.map → chunk-GSZZDYPV.js.map} +0 -0
  72. /package/dist/{chunk-B4DWUU4Y.js.map → chunk-GYAQYECK.js.map} +0 -0
  73. /package/dist/{chunk-GYHXV2LR.js.map → chunk-K2YN562I.js.map} +0 -0
  74. /package/dist/{chunk-SSCUP23X.js.map → chunk-MSAWMGWJ.js.map} +0 -0
  75. /package/dist/{chunk-T5RZ3HWS.js.map → chunk-OFQDECMZ.js.map} +0 -0
  76. /package/dist/{chunk-72JNABPO.js.map → chunk-TIF52FMJ.js.map} +0 -0
  77. /package/dist/{chunk-67VNEM5N.js.map → chunk-TLCTS7CZ.js.map} +0 -0
  78. /package/dist/{chunk-VFQI3CDL.js.map → chunk-VXCIFQX7.js.map} +0 -0
  79. /package/dist/{chunk-5H6FHFHD.js.map → chunk-XX5M3TJJ.js.map} +0 -0
  80. /package/dist/{chunk-LUO43IZE.js.map → chunk-YOFYG5ZI.js.map} +0 -0
  81. /package/dist/{subscription-core-codex-SUO2TISD.js.map → subscription-core-codex-L7NPFWAH.js.map} +0 -0
  82. /package/dist/{subscription-core-codex-compat-SDHM4XG4.js.map → subscription-core-codex-compat-AK2UMLE3.js.map} +0 -0
@@ -0,0 +1,55 @@
1
+ import { sql } from "drizzle-orm";
2
+ import { rawRows, type Database } from "./database";
3
+ import { fromPostgresLosslessJson } from "./lossless-json";
4
+ import { historyCallId, historyItemType } from "./session-tool-call-settlement";
5
+
6
+ /** Receipt proof metadata only. Canonical model history is never projected here. */
7
+ export async function readSessionHistoryToolIdentityPage(
8
+ tx: Pick<Database, "execute">,
9
+ scope: { workspaceId: string; sessionId: string; turnId: string },
10
+ before: number | null,
11
+ limit: number,
12
+ ): Promise<Array<{ position: number; callId: string | null; type: string | null }>> {
13
+ const rows = await rawRows<{
14
+ position: string;
15
+ item: Record<string, unknown>;
16
+ itemCodecVersion: number | null;
17
+ }>(
18
+ tx,
19
+ sql`
20
+ select page.position, page.item_codec_version as "itemCodecVersion",
21
+ jsonb_build_object(
22
+ 'type', identity.type, 'callId', identity."callId", 'call_id', identity.call_id,
23
+ 'id', identity.id, 'providerData', jsonb_build_object(
24
+ 'callId', identity."providerData"->'callId',
25
+ 'call_id', identity."providerData"->'call_id'
26
+ )
27
+ ) as item
28
+ from (
29
+ select position, item, item_codec_version from session_history_items
30
+ where workspace_id = ${scope.workspaceId}::uuid
31
+ and session_id = ${scope.sessionId}::uuid and turn_id = ${scope.turnId}::uuid
32
+ ${before === null ? sql`` : sql`and position < ${before}`}
33
+ order by position desc limit ${limit}
34
+ ) page
35
+ cross join lateral jsonb_to_record(
36
+ case when jsonb_typeof(page.item) in ('object', 'null') then page.item else '{}'::jsonb end
37
+ ) as identity(type jsonb, "callId" jsonb, call_id jsonb, id jsonb, "providerData" jsonb)
38
+ order by page.position desc
39
+ `,
40
+ );
41
+ // The synchronized JSONB mirror has the same field values as item_ordered.
42
+ // Decode only these fields with the original row's codec, then use the same
43
+ // identity precedence/type checks as the full-history reader. jsonb_to_record
44
+ // extracts them in one pass rather than detoasting a large output per field.
45
+ // A malformed JSON null still rejects the read; other primitive/array rows
46
+ // have no named identities, just as with the previous JavaScript lookup.
47
+ return rows.map((row) => {
48
+ const item = fromPostgresLosslessJson(row.item, row.itemCodecVersion);
49
+ return {
50
+ position: Number(row.position),
51
+ callId: historyCallId(item),
52
+ type: historyItemType(item),
53
+ };
54
+ });
55
+ }
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { and, asc, eq, sql, type SQL } from "drizzle-orm";
2
+ import { and, eq, sql, type SQL } from "drizzle-orm";
3
3
  import { z } from "zod";
4
4
  import type {
5
5
  SessionMessageSearchRequest,
@@ -35,6 +35,19 @@ const Cursor = z
35
35
  })
36
36
  .strict();
37
37
  type Position = z.infer<typeof Position>;
38
+ type CandidateRow = {
39
+ visited: number;
40
+ rank: number | null;
41
+ sessionId: string | null;
42
+ eventId: string | null;
43
+ sequence: number | null;
44
+ turnId: string | null;
45
+ type: "user.message" | "agent.message.completed" | null;
46
+ sessionTitle: string | null;
47
+ messageId: unknown;
48
+ codec: number | null;
49
+ smallText: unknown;
50
+ };
38
51
 
39
52
  export class SessionMessageSearchCursorError extends Error {
40
53
  constructor() {
@@ -50,6 +63,82 @@ export function sessionMessageLiteralPattern(query: string): RegExp {
50
63
  return new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "iu");
51
64
  }
52
65
 
66
+ // Highest code point with a Unicode case mapping (Adlam ends at U+1E943).
67
+ const LAST_CASED_CODE_POINT = 0x1ffff;
68
+ let caseClasses: ReadonlyMap<number, readonly number[]> | undefined;
69
+
70
+ /** Every code point that `sessionMessageLiteralPattern` treats as the same
71
+ * character, keyed by each member. Characters absent from the map only match
72
+ * themselves. Built once from the runtime's own case mappings and verified
73
+ * against its `iu` matching, so the database filter agrees with the exact
74
+ * JavaScript matcher (including K/k/U+212A, s/S/U+017F and the Greek sigmas).
75
+ */
76
+ export function sessionMessageCaseClasses(): ReadonlyMap<number, readonly number[]> {
77
+ if (caseClasses) return caseClasses;
78
+ const edges = new Map<number, Set<number>>();
79
+ const link = (a: number, b: number) => {
80
+ let set = edges.get(a);
81
+ if (!set) edges.set(a, (set = new Set()));
82
+ set.add(b);
83
+ };
84
+ for (let cp = 0; cp <= LAST_CASED_CODE_POINT; cp++) {
85
+ if (cp >= 0xd800 && cp <= 0xdfff) continue;
86
+ const char = String.fromCodePoint(cp);
87
+ for (const mapped of [char.toLowerCase(), char.toUpperCase()]) {
88
+ if (mapped === char) continue;
89
+ const target = mapped.codePointAt(0)!;
90
+ if (String.fromCodePoint(target) !== mapped) continue;
91
+ if (!sessionMessageLiteralPattern(char).test(mapped)) continue;
92
+ link(cp, target);
93
+ link(target, cp);
94
+ }
95
+ }
96
+ const classes = new Map<number, readonly number[]>();
97
+ for (const start of edges.keys()) {
98
+ if (classes.has(start)) continue;
99
+ const members = new Set([start]);
100
+ for (const member of members) for (const next of edges.get(member) ?? []) members.add(next);
101
+ const sorted = [...members].sort((a, b) => a - b);
102
+ for (const member of sorted) classes.set(member, sorted);
103
+ }
104
+ return (caseClasses = classes);
105
+ }
106
+
107
+ /** A PostgreSQL advanced regular expression matching exactly the strings that
108
+ * contain `query` under `sessionMessageLiteralPattern`. Every character is a
109
+ * \u/\U escape, so no character is special and the database collation plays
110
+ * no part. Null when the query holds a character PostgreSQL text cannot store
111
+ * (NUL or a lone surrogate): only losslessly encoded text can contain it.
112
+ */
113
+ export function sessionMessagePostgresPattern(query: string): string | null {
114
+ const escape = (cp: number) =>
115
+ cp <= 0xffff
116
+ ? `\\u${cp.toString(16).padStart(4, "0")}`
117
+ : `\\U${cp.toString(16).padStart(8, "0")}`;
118
+ let pattern = "";
119
+ for (const char of query) {
120
+ const cp = char.codePointAt(0)!;
121
+ if (cp === 0 || (cp >= 0xd800 && cp <= 0xdfff)) return null;
122
+ const members = sessionMessageCaseClasses().get(cp);
123
+ pattern += members ? `[${members.map(escape).join("")}]` : escape(cp);
124
+ }
125
+ return pattern;
126
+ }
127
+
128
+ /** Database tests over stored message text: `matched` is exact for ordinary
129
+ * text; `encoded` marks losslessly encoded strings, which only the decoding
130
+ * reader can inspect and so always remain candidates. */
131
+ export function sessionMessageTextMatchSql(
132
+ query: string,
133
+ text: SQL,
134
+ ): { matched: SQL; encoded: SQL } {
135
+ const pattern = sessionMessagePostgresPattern(query);
136
+ return {
137
+ matched: pattern === null ? sql`false` : sql`(${text} ~ ${pattern})`,
138
+ encoded: sql`(left(${text}, ${LOSSLESS_JSON_STRING_PREFIX.length}) = ${LOSSLESS_JSON_STRING_PREFIX})`,
139
+ };
140
+ }
141
+
53
142
  export function sessionMessageSearchSnippet(text: string, start: number, length: number) {
54
143
  let left = Math.max(0, start - 120);
55
144
  let right = Math.min(text.length, start + length + 120);
@@ -76,7 +165,6 @@ export async function scanSessionMessages(
76
165
  signal?: AbortSignal,
77
166
  ): Promise<SessionMessageSearchResponse> {
78
167
  signal?.throwIfAborted();
79
- const started = performance.now();
80
168
  const binding = createHash("sha256")
81
169
  .update(
82
170
  JSON.stringify([
@@ -127,6 +215,13 @@ export async function scanSessionMessages(
127
215
  ? sql`${e.sessionId} > ${position.sessionId}::uuid`
128
216
  : sql`(${e.sessionId} > ${position.sessionId}::uuid or (${e.sessionId} = ${position.sessionId}::uuid and ${position.done ? sql`${e.sequence} > ${position.sequence}` : sql`${e.sequence} >= ${position.sequence}`}))`
129
217
  : sql`true`;
218
+ // One statement checks every remaining message's text in PostgreSQL and
219
+ // returns only the first candidates that contain the literal. The cheap
220
+ // list/visibility predicates and the text test run once per message; the
221
+ // correlated canonical-copy and unclaimed-prompt checks run only for
222
+ // candidates, lazily in key order, until the batch is full. A no-hit scan
223
+ // finishes in one request instead of one request per 33 messages.
224
+ //
130
225
  // Correlate the exact durable message identity, not turn status or latest
131
226
  // model context. The latest full completion for a provider message wins,
132
227
  // so an earlier shorter completion cannot hide later retained text. Same-
@@ -135,27 +230,15 @@ export async function scanSessionMessages(
135
230
  // Suppression requires the competing copy to retain searchable string text
136
231
  // itself: a later text-less completion (failed/empty retry sharing the
137
232
  // provider message id) must never hide the earlier complete text.
138
- const identities = await db
139
- .select({
140
- sessionId: e.sessionId,
141
- eventId: e.id,
142
- sequence: e.sequence,
143
- turnId: e.turnId,
144
- type: sql<"user.message" | "agent.message.completed">`${e.type}`,
145
- sessionTitle: sql<
146
- string | null
147
- >`case when octet_length(${schema.sessions.title}) <= 2048 then ${schema.sessions.title} else null end`,
148
- messageId: sql<unknown>`case when octet_length((${e.payload}->'messageId')::text) <= 2048 then ${e.payload}->'messageId' else null end`,
149
- codec: e.payloadCodecVersion,
150
- smallText: sql<unknown>`case when octet_length(${e.payload}->>'text') <= 8192 then ${e.payload}->'text' else null end`,
151
- })
152
- .from(e)
153
- .innerJoin(
154
- schema.sessions,
155
- and(eq(schema.sessions.workspaceId, e.workspaceId), eq(schema.sessions.id, e.sessionId)),
156
- )
157
- .where(
158
- and(
233
+ const textMatch = sessionMessageTextMatchSql(request.query, sql`${e.payload}->>'text'`);
234
+ const rows: CandidateRow[] = await db.execute<CandidateRow>(sql`
235
+ with eligible as materialized (
236
+ select ${e.id} as id, ${e.sessionId} as session_id, ${e.sequence} as sequence,
237
+ ${textMatch.matched} as matched, ${textMatch.encoded} as encoded
238
+ from ${e}
239
+ inner join ${schema.sessions}
240
+ on ${schema.sessions.workspaceId} = ${e.workspaceId} and ${schema.sessions.id} = ${e.sessionId}
241
+ where ${and(
159
242
  eq(e.workspaceId, workspaceId),
160
243
  ...filters,
161
244
  boundary,
@@ -164,8 +247,41 @@ export async function scanSessionMessages(
164
247
  sql`(${e.turnAssociation} is null or ${e.turnAssociation} = 'current')`,
165
248
  sql`${e.duplicateOfEventId} is null`,
166
249
  sql`jsonb_typeof(${e.payload}->'text') = 'string'`,
167
- excludeUnclaimedHumanPromptEventFilter(workspaceId),
168
- sql`(${e.type} <> 'agent.message.completed' or not exists (
250
+ )}
251
+ ), visited as (
252
+ select count(*)::int as visited from eligible
253
+ )
254
+ select visited.visited, candidate.*
255
+ from visited
256
+ left join lateral (
257
+ select * from (
258
+ select valid.*${
259
+ request.groupBy === "session"
260
+ ? sql`, (count(*) filter (where valid.definite) over (partition by valid."sessionId"
261
+ order by valid.sequence rows between unbounded preceding and 1 preceding))::int as earlier`
262
+ : sql``
263
+ }
264
+ from (
265
+ select ranked.rank, ranked.session_id as "sessionId", ${e.id} as "eventId",
266
+ ranked.sequence as sequence, ${e.turnId} as "turnId", ${e.type} as type,
267
+ ranked.matched and not ranked.encoded as definite,
268
+ case when octet_length(${schema.sessions.title}) <= 2048 then ${schema.sessions.title} else null end as "sessionTitle",
269
+ case when octet_length((${e.payload}->'messageId')::text) <= 2048 then ${e.payload}->'messageId' else null end as "messageId",
270
+ ${e.payloadCodecVersion} as codec,
271
+ case when octet_length(${e.payload}->>'text') <= 8192 then ${e.payload}->'text' else null end as "smallText"
272
+ from (
273
+ select eligible.id, eligible.session_id, eligible.sequence, eligible.matched, eligible.encoded,
274
+ (row_number() over (order by eligible.session_id, eligible.sequence))::int as rank
275
+ from eligible
276
+ order by eligible.session_id, eligible.sequence
277
+ offset 0
278
+ ) ranked
279
+ inner join ${e} on ${e.workspaceId} = ${workspaceId} and ${e.id} = ranked.id
280
+ inner join ${schema.sessions}
281
+ on ${schema.sessions.workspaceId} = ${e.workspaceId} and ${schema.sessions.id} = ${e.sessionId}
282
+ where (ranked.matched or ranked.encoded)
283
+ and ${excludeUnclaimedHumanPromptEventFilter(workspaceId)}
284
+ and (${e.type} <> 'agent.message.completed' or not exists (
169
285
  select 1 from ${schema.sessionEvents} other_message
170
286
  where other_message.workspace_id = ${workspaceId} and other_message.session_id = ${e.sessionId}
171
287
  and other_message.turn_id = ${e.turnId}
@@ -181,15 +297,46 @@ export async function scanSessionMessages(
181
297
  and other_message.sequence < ${e.sequence}
182
298
  and other_message.payload->'text' = ${e.payload}->'text'
183
299
  and (other_message.payload_codec_version is not distinct from ${e.payloadCodecVersion}
184
- or left(${e.payload}->>'text', ${LOSSLESS_JSON_STRING_PREFIX.length}) <> ${LOSSLESS_JSON_STRING_PREFIX})))))`,
185
- ),
186
- )
187
- .orderBy(asc(e.sessionId), asc(e.sequence))
188
- .limit(33);
300
+ or left(${e.payload}->>'text', ${LOSSLESS_JSON_STRING_PREFIX.length}) <> ${LOSSLESS_JSON_STRING_PREFIX})))))
301
+ order by ranked.session_id, ranked.sequence
302
+ offset 0
303
+ ) valid
304
+ ) grouped
305
+ -- Grouping needs only each session's first certain hit. Encoded text
306
+ -- stays a candidate because only the decoding reader can confirm it.
307
+ where ${request.groupBy === "session" ? sql`grouped.earlier = 0` : sql`true`}
308
+ order by grouped."sessionId", grouped.sequence
309
+ limit 33
310
+ ) candidate on true
311
+ order by candidate.rank
312
+ `);
313
+ const visited = rows[0]?.visited ?? 0;
314
+ const identities = rows.flatMap((row: CandidateRow) =>
315
+ row.rank === null ||
316
+ row.sessionId === null ||
317
+ row.eventId === null ||
318
+ row.sequence === null ||
319
+ row.type === null
320
+ ? []
321
+ : [
322
+ {
323
+ ...row,
324
+ rank: row.rank,
325
+ sessionId: row.sessionId,
326
+ eventId: row.eventId,
327
+ sequence: row.sequence,
328
+ type: row.type,
329
+ },
330
+ ],
331
+ );
332
+ // The cursor's message being resumed mid-text is the first visited row
333
+ // again; it was already counted by the request that started it.
334
+ const visitedBefore = scanned - (position && !position.done ? 1 : 0);
335
+ const budgetStarted = performance.now();
189
336
  let identityIndex = 0;
190
337
  for (let window = 0; window < 32 && matches.length < (request.limit ?? 20); window++) {
191
338
  signal?.throwIfAborted();
192
- if (window > 0 && performance.now() - started >= 1_500) break;
339
+ if (window > 0 && performance.now() - budgetStarted >= 1_500) break;
193
340
  const identity = identities[identityIndex];
194
341
  if (!identity) {
195
342
  // Grouping can drain a full candidate batch in fewer than 32 windows.
@@ -203,7 +350,7 @@ export async function scanSessionMessages(
203
350
  position.sessionId === identity.sessionId &&
204
351
  position.sequence === identity.sequence;
205
352
  if (!continuing) {
206
- scanned++;
353
+ scanned = visitedBefore + identity.rank;
207
354
  position = {
208
355
  sessionId: identity.sessionId,
209
356
  sequence: identity.sequence,
@@ -326,6 +473,7 @@ export async function scanSessionMessages(
326
473
  };
327
474
  }
328
475
  if (identityIndex >= identities.length && identities.length < 33) exhausted = true;
476
+ if (exhausted) scanned = visitedBefore + visited;
329
477
  }
330
478
  signal?.throwIfAborted();
331
479
  return {
@@ -339,7 +487,14 @@ export async function scanSessionMessages(
339
487
  exhausted || !position
340
488
  ? null
341
489
  : Buffer.from(
342
- JSON.stringify({ version: 1, binding, position, scanned, matched, occurrences }),
490
+ JSON.stringify({
491
+ version: 1,
492
+ binding,
493
+ position,
494
+ scanned,
495
+ matched,
496
+ occurrences,
497
+ }),
343
498
  ).toString("base64url"),
344
499
  };
345
500
  }
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * Two scopes, each with its own authority and nothing borrowed:
11
11
  * - `organization_administrator`: the organization context (no workspace),
12
- * the organization membership lifecycle lock, the caller's subject and the
12
+ * a shared organization membership lifecycle lock, the caller's subject and the
13
13
  * organization administrator check (the predicate the connection policies
14
14
  * use), in every transaction. Only a shared, organization-managed
15
15
  * connection (not one a workspace manages) of the provider's registered
@@ -200,15 +200,17 @@ export const subscriptionCoreAdministeredConnections = memoByProvider(
200
200
  { accountId: scope.accountId, workspaceId: null },
201
201
  async (tx) => {
202
202
  // The organization membership lifecycle key, before the
203
- // administrator check, so a demotion cannot commit while
204
- // this transaction acts on its result. A bounded wait: a
205
- // busy organization refuses the read rather than queueing.
203
+ // administrator check, shared between inspections of independent
204
+ // connections. Lifecycle writers remain exclusive, so a demotion
205
+ // cannot commit while this transaction acts on its result. The
206
+ // bounded wait refuses a busy organization rather than queueing
207
+ // indefinitely.
206
208
  await tx.execute(
207
209
  sql`select set_config('lock_timeout', ${ORGANIZATION_MEMBERSHIP_LOCK_TIMEOUT}, true)`,
208
210
  );
209
211
  try {
210
212
  await tx.execute(
211
- sql`select pg_advisory_xact_lock(hashtextextended(${`organization-membership:${scope.accountId}`}, 0))`,
213
+ sql`select pg_advisory_xact_lock_shared(hashtextextended(${`organization-membership:${scope.accountId}`}, 0))`,
212
214
  );
213
215
  } catch (error) {
214
216
  if (nestedPostgresSqlState(error) === "55P03") throw new NotVisible();
@@ -44,11 +44,11 @@ export function blockedSubscriptionWindows(
44
44
  provider: Provider,
45
45
  usage: ReturnType<typeof projectSubscriptionCoreAccountUsage>,
46
46
  now: number,
47
- ): Partial<Record<Window, number>> {
48
- const blocked: Partial<Record<Window, number>> = {};
47
+ ): Partial<Record<Window, number | null>> {
48
+ const blocked: Partial<Record<Window, number | null>> = {};
49
49
  if (!usage) return blocked;
50
50
  const block = (id: Window, until: number) => {
51
- if (until > now) blocked[id] = Math.max(blocked[id] ?? 0, until);
51
+ if (until > now && blocked[id] !== null) blocked[id] = Math.max(blocked[id] ?? 0, until);
52
52
  };
53
53
  const windows = usage.windows.flatMap((window) => {
54
54
  const id = windowId(provider, window.id);
@@ -56,8 +56,10 @@ export function blockedSubscriptionWindows(
56
56
  return id === null ? [] : [{ id, resetsAt, status: window.status }];
57
57
  });
58
58
  for (const window of windows) {
59
- if (window.status === "exhausted" && window.resetsAt !== null)
60
- block(window.id, window.resetsAt);
59
+ if (window.status === "exhausted") {
60
+ if (window.resetsAt === null) blocked[window.id] = null;
61
+ else block(window.id, window.resetsAt);
62
+ }
61
63
  }
62
64
  const until = epoch(usage.exhaustedUntil);
63
65
  if (until !== null && until > now) {
@@ -11,7 +11,7 @@ import { parseVerifiedAttemptToolCatalog } from "@opengeni/codemode";
11
11
  import { and, desc, eq, lt, or, sql } from "drizzle-orm";
12
12
  import { isDeepStrictEqual } from "node:util";
13
13
  import type { Database } from "./database";
14
- import { withRlsContext, withWorkspaceRls } from "./database";
14
+ import { withRlsContext, withWorkspaceRls, withWorkspaceSubjectRls } from "./database";
15
15
  import * as schema from "./schema";
16
16
  import { hydrateStoredAttemptToolCatalog } from "./session-content-blobs";
17
17
  import { sameSitePublicationRequest, sitePublicationRequest } from "./site-publication-request";
@@ -185,6 +185,50 @@ export class WorkspaceArtifactNotFoundError extends Error {
185
185
  readonly name = "WorkspaceArtifactNotFoundError";
186
186
  }
187
187
 
188
+ /**
189
+ * Records that a person opened a Site in the console, for the order of their
190
+ * own Site rows. Only a Site this viewer can see is recorded; the row grants
191
+ * nothing. Repeated opens only move the time forward.
192
+ */
193
+ export async function recordSiteOpened(
194
+ db: Database,
195
+ input: {
196
+ workspaceId: string;
197
+ subjectId: string;
198
+ viewer: WorkspaceArtifactViewer;
199
+ siteId: string;
200
+ },
201
+ ): Promise<{ openedAt: string }> {
202
+ return await withWorkspaceSubjectRls(db, input.workspaceId, input.subjectId, async (scoped) => {
203
+ const [site] = await scoped
204
+ .select({ accountId: schema.workspaceArtifacts.accountId })
205
+ .from(schema.workspaceArtifacts)
206
+ .where(
207
+ and(
208
+ eq(schema.workspaceArtifacts.workspaceId, input.workspaceId),
209
+ eq(schema.workspaceArtifacts.id, input.siteId),
210
+ visibleTo(input.viewer),
211
+ ),
212
+ )
213
+ .limit(1);
214
+ if (!site) throw new WorkspaceArtifactNotFoundError("Site not found");
215
+ const [row] = await scoped
216
+ .insert(schema.siteOpens)
217
+ .values({
218
+ accountId: site.accountId,
219
+ workspaceId: input.workspaceId,
220
+ subjectId: input.subjectId,
221
+ siteId: input.siteId,
222
+ })
223
+ .onConflictDoUpdate({
224
+ target: [schema.siteOpens.workspaceId, schema.siteOpens.subjectId, schema.siteOpens.siteId],
225
+ set: { openedAt: sql`greatest(${schema.siteOpens.openedAt}, now())` },
226
+ })
227
+ .returning({ openedAt: schema.siteOpens.openedAt });
228
+ return { openedAt: row!.openedAt.toISOString() };
229
+ });
230
+ }
231
+
188
232
  export class WorkspaceArtifactConflictError extends Error {
189
233
  readonly name = "WorkspaceArtifactConflictError";
190
234
  constructor(