Skip to content

Commit fd37ff2

Browse files
dcramercodex
andcommitted
ref(migrations): Add a versioned helper boundary
Allow migrations to import append-only infrastructure helpers from @sentry/junior/migration-helpers/v1 while continuing to reject unversioned Junior runtime imports. Keep one-off Redis transformations and backfill decisions in their journal entries, replace generated capsules for migrations 0004 through 0006 with readable source, and publish explicit helper types without private module references. Co-Authored-By: GPT-5.6 Codex <noreply@openai.com>
1 parent b26c218 commit fd37ff2

17 files changed

Lines changed: 760 additions & 5157 deletions

File tree

packages/junior-migrations/README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ same database adapter drives the journal ledger and is exposed as
3535
`context.database`, so migration files never own connection or driver setup.
3636

3737
The runner rejects runtime imports of application source, relative modules,
38-
and `@sentry/junior`. External package imports are allowed when the migration
39-
needs a stable library dependency; migration-specific implementation must
40-
still remain in the migration file. Add a new API version rather than changing
41-
an existing migration capability contract.
38+
and unversioned `@sentry/junior` modules. Migrations may import the append-only
39+
`@sentry/junior/migration-helpers/v1` surface for parsing primitives, adapters,
40+
stores, and key resolution. One-off migration decisions and data transforms
41+
must still remain in the journal entry. Add a new helper or capability version
42+
rather than changing an existing contract.

packages/junior-migrations/src/journal.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ async function optionalFile(path: string): Promise<string | undefined> {
5151
}
5252

5353
function validateTypeScriptSource(tag: string, source: string): void {
54+
const allowedRuntimeImports = new Set([
55+
"@sentry/junior/migration-helpers/v1",
56+
]);
5457
const imports = source.matchAll(
5558
/^\s*import\s+([^;]+?)\s+from\s+["']([^"']+)["'];?/gm,
5659
);
@@ -65,8 +68,9 @@ function validateTypeScriptSource(tag: string, source: string): void {
6568
specifier.startsWith(".") ||
6669
specifier.startsWith("/") ||
6770
specifier.startsWith("@/") ||
68-
specifier === "@sentry/junior" ||
69-
specifier.startsWith("@sentry/junior/")
71+
((specifier === "@sentry/junior" ||
72+
specifier.startsWith("@sentry/junior/")) &&
73+
!allowedRuntimeImports.has(specifier))
7074
) {
7175
throw new Error(
7276
`TypeScript migration ${tag} cannot import application runtime code`,

packages/junior-migrations/src/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ export interface MigrationStateV1 {
3131
): Promise<void>;
3232
connect(): Promise<void>;
3333
delete(key: string): Promise<void>;
34-
get(key: string): Promise<unknown | undefined>;
35-
getList(key: string): Promise<unknown[]>;
34+
get<T = unknown>(key: string): Promise<T | null | undefined>;
35+
getList<T = unknown>(key: string): Promise<T[]>;
3636
releaseLock(lock: MigrationLockV1): Promise<void>;
3737
set(key: string, value: unknown, ttlMs?: number): Promise<void>;
3838
setIfNotExists(key: string, value: unknown, ttlMs?: number): Promise<boolean>;

packages/junior-migrations/tests/journal.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,18 @@ describe("resolveMigrations", () => {
6262
);
6363
});
6464

65+
it("allows versioned Junior migration helpers", async () => {
66+
const folder = await migrationFolder(["0000_helpers"]);
67+
await writeFile(
68+
join(folder, "0000_helpers.ts"),
69+
'import { isRecord } from "@sentry/junior/migration-helpers/v1";\nexport default { apiVersion: 1, async up() { isRecord({}); } };\n',
70+
);
71+
72+
await expect(resolveMigrations(folder)).resolves.toMatchObject([
73+
{ kind: "typescript", tag: "0000_helpers" },
74+
]);
75+
});
76+
6577
it("rejects ambiguous migration files", async () => {
6678
const folder = await migrationFolder(["0000_ambiguous"]);
6779
await writeFile(join(folder, "0000_ambiguous.sql"), "SELECT 1;");
Lines changed: 98 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,81 @@
1-
// @ts-nocheck -- frozen migration capsule; do not refactor against current Junior internals.
2-
/* eslint-disable no-unused-vars */
3-
4-
// packages/junior/src/cli/upgrade/migrations/agent-turn-session-actor.ts
51
import { THREAD_STATE_TTL_MS } from "chat";
2+
import type {
3+
MigrationRedisV1,
4+
MigrationStateV1,
5+
MigrationV1,
6+
} from "@sentry/junior-migrations";
7+
import {
8+
isRecord,
9+
migrationRedisKey,
10+
toOptionalString,
11+
} from "@sentry/junior/migration-helpers/v1";
612

7-
// packages/junior/src/chat/coerce.ts
8-
function toOptionalString(value) {
9-
return typeof value === "string" && value.trim() ? value : void 0;
10-
}
11-
function isRecord(value) {
12-
return typeof value === "object" && value !== null;
13-
}
13+
type StateAdapter = MigrationStateV1;
14+
type RedisStateAdapter = { getClient(): MigrationRedisV1 };
15+
type MigrationContext = {
16+
io?: { info(message: string): void };
17+
redisStateAdapter?: RedisStateAdapter;
18+
stateAdapter: StateAdapter;
19+
};
20+
type MigrationResult = {
21+
existing: number;
22+
migrated: number;
23+
missing: number;
24+
scanned: number;
25+
};
1426

15-
// migration:config
16-
function getChatConfig() {
17-
const databaseUrl = process.env.DATABASE_URL;
18-
if (!databaseUrl) throw new Error("DATABASE_URL is required");
19-
const configured = process.env.JUNIOR_DATABASE_DRIVER;
20-
const url = new URL(databaseUrl);
21-
const driver =
22-
configured === "postgres" || configured === "neon"
23-
? configured
24-
: url.hostname === "localhost" || url.hostname === "127.0.0.1"
25-
? "postgres"
26-
: "neon";
27-
return {
28-
bot: { modelContextWindowTokens: void 0 },
29-
sql: { databaseUrl, driver },
30-
state: {
31-
keyPrefix: process.env.JUNIOR_STATE_KEY_PREFIX?.trim() || void 0,
32-
adapter: process.env.REDIS_URL ? "redis" : "memory",
33-
redisUrl: process.env.REDIS_URL,
34-
},
35-
};
27+
const AGENT_TURN_SESSION_PREFIX = "junior:agent_turn_session";
28+
const AGENT_TURN_SESSION_INDEX_KEY = `${AGENT_TURN_SESSION_PREFIX}:index`;
29+
const AGENT_TURN_SESSION_INDEX_MAX_LENGTH = 5_000;
30+
const REDIS_SCAN_COUNT = 500;
31+
32+
type RedisCommandClient = {
33+
sendCommand<T = unknown>(args: readonly string[]): Promise<T>;
34+
};
35+
36+
interface MigratedValue {
37+
changed: boolean;
38+
value: unknown;
3639
}
3740

38-
// packages/junior/src/cli/upgrade/migrations/agent-turn-session-actor.ts
39-
var AGENT_TURN_SESSION_PREFIX = "junior:agent_turn_session";
40-
var AGENT_TURN_SESSION_INDEX_KEY = `${AGENT_TURN_SESSION_PREFIX}:index`;
41-
var AGENT_TURN_SESSION_INDEX_MAX_LENGTH = 5e3;
42-
var REDIS_SCAN_COUNT = 500;
43-
function conversationIndexKey(conversationId) {
41+
function conversationIndexKey(conversationId: string): string {
4442
return `${AGENT_TURN_SESSION_PREFIX}:conversation:${conversationId}:index`;
4543
}
46-
function sessionRecordKey(conversationId, sessionId) {
44+
45+
function sessionRecordKey(conversationId: string, sessionId: string): string {
4746
return `${AGENT_TURN_SESSION_PREFIX}:${conversationId}:${sessionId}`;
4847
}
49-
function logicalConversationIndexPrefix() {
50-
const prefix = getChatConfig().state.keyPrefix;
51-
return [
52-
...(prefix ? [prefix] : []),
53-
`${AGENT_TURN_SESSION_PREFIX}:conversation:`,
54-
].join(":");
48+
49+
function logicalConversationIndexPrefix(): string {
50+
return migrationRedisKey(`${AGENT_TURN_SESSION_PREFIX}:conversation:`);
5551
}
56-
function conversationIdFromRedisListKey(key) {
52+
53+
function conversationIdFromRedisListKey(key: string): string | undefined {
5754
const marker = `:list:${logicalConversationIndexPrefix()}`;
5855
const markerIndex = key.indexOf(marker);
5956
if (markerIndex < 0 || !key.endsWith(":index")) {
60-
return void 0;
57+
return undefined;
6158
}
6259
return toOptionalString(
6360
key.slice(markerIndex + marker.length, -":index".length),
6461
);
6562
}
66-
async function discoverRedisConversationIds(redisStateAdapter) {
67-
const client = redisStateAdapter?.getClient();
63+
64+
async function discoverRedisConversationIds(
65+
redisStateAdapter: RedisStateAdapter | undefined,
66+
): Promise<string[]> {
67+
const client = redisStateAdapter?.getClient() as
68+
| RedisCommandClient
69+
| undefined;
6870
if (!client) {
6971
return [];
7072
}
71-
const conversationIds = /* @__PURE__ */ new Set();
73+
74+
const conversationIds = new Set<string>();
7275
const match = `*:list:${logicalConversationIndexPrefix()}*:index`;
7376
let cursor = "0";
7477
do {
75-
const reply = await client.sendCommand([
78+
const reply = await client.sendCommand<unknown>([
7679
"SCAN",
7780
cursor,
7881
"MATCH",
@@ -101,41 +104,55 @@ async function discoverRedisConversationIds(redisStateAdapter) {
101104
}
102105
}
103106
} while (cursor !== "0");
107+
104108
return [...conversationIds];
105109
}
106-
function migrateRequesterToActor(value) {
107-
if (!isRecord(value) || value.requester === void 0) {
110+
111+
function migrateRequesterToActor(value: unknown): MigratedValue {
112+
if (!isRecord(value) || value.requester === undefined) {
108113
return { changed: false, value };
109114
}
115+
110116
const { requester, ...record } = value;
111117
return {
112118
changed: true,
113119
value: {
114120
...record,
115-
...(record.actor === void 0 ? { actor: requester } : {}),
121+
...(record.actor === undefined ? { actor: requester } : {}),
116122
},
117123
};
118124
}
119-
async function rewriteList(args) {
125+
126+
async function rewriteList(args: {
127+
key: string;
128+
maxLength?: number;
129+
stateAdapter: StateAdapter;
130+
}): Promise<{ migrated: number; values: unknown[] }> {
120131
const values = await args.stateAdapter.getList(args.key);
121132
const migrated = values.map(migrateRequesterToActor);
122133
const changed = migrated.filter((entry) => entry.changed).length;
123134
if (changed === 0) {
124135
return { migrated: 0, values };
125136
}
137+
126138
await args.stateAdapter.delete(args.key);
127139
for (const entry of migrated) {
128140
await args.stateAdapter.appendToList(args.key, entry.value, {
129-
...(args.maxLength !== void 0 ? { maxLength: args.maxLength } : {}),
141+
...(args.maxLength !== undefined ? { maxLength: args.maxLength } : {}),
130142
ttlMs: THREAD_STATE_TTL_MS,
131143
});
132144
}
133145
return { migrated: changed, values: migrated.map((entry) => entry.value) };
134146
}
135-
async function migrateSessionRecord(args) {
147+
148+
async function migrateSessionRecord(args: {
149+
conversationId: string;
150+
sessionId: string;
151+
stateAdapter: StateAdapter;
152+
}): Promise<"existing" | "migrated" | "missing"> {
136153
const key = sessionRecordKey(args.conversationId, args.sessionId);
137-
const existing = await args.stateAdapter.get(key);
138-
if (existing === void 0) {
154+
const existing = await args.stateAdapter.get<unknown>(key);
155+
if (existing === undefined) {
139156
return "missing";
140157
}
141158
const migrated = migrateRequesterToActor(existing);
@@ -145,8 +162,12 @@ async function migrateSessionRecord(args) {
145162
await args.stateAdapter.set(key, migrated.value, THREAD_STATE_TTL_MS);
146163
return "migrated";
147164
}
148-
async function migrateAgentTurnSessionActor(context) {
149-
const result = {
165+
166+
/** Rewrite retained turn-session requester fields to actor fields. */
167+
export async function migrateAgentTurnSessionActor(
168+
context: MigrationContext,
169+
): Promise<MigrationResult> {
170+
const result: MigrationResult = {
150171
existing: 0,
151172
migrated: 0,
152173
missing: 0,
@@ -159,8 +180,9 @@ async function migrateAgentTurnSessionActor(context) {
159180
});
160181
result.scanned += global.values.length;
161182
result.migrated += global.migrated;
162-
const conversations = /* @__PURE__ */ new Set();
163-
const sessions = /* @__PURE__ */ new Set();
183+
184+
const conversations = new Set<string>();
185+
const sessions = new Set<string>();
164186
for (const conversationId of await discoverRedisConversationIds(
165187
context.redisStateAdapter,
166188
)) {
@@ -177,9 +199,10 @@ async function migrateAgentTurnSessionActor(context) {
177199
}
178200
conversations.add(conversationId);
179201
if (sessionId) {
180-
sessions.add(`${conversationId}\0${sessionId}`);
202+
sessions.add(`${conversationId}\u0000${sessionId}`);
181203
}
182204
}
205+
183206
for (const conversationId of conversations) {
184207
const conversation = await rewriteList({
185208
key: conversationIndexKey(conversationId),
@@ -193,12 +216,13 @@ async function migrateAgentTurnSessionActor(context) {
193216
}
194217
const sessionId = toOptionalString(value.sessionId);
195218
if (sessionId) {
196-
sessions.add(`${conversationId}\0${sessionId}`);
219+
sessions.add(`${conversationId}\u0000${sessionId}`);
197220
}
198221
}
199222
}
223+
200224
for (const session of sessions) {
201-
const separator = session.indexOf("\0");
225+
const separator = session.indexOf("\u0000");
202226
const conversationId = session.slice(0, separator);
203227
const sessionId = session.slice(separator + 1);
204228
result.scanned += 1;
@@ -215,24 +239,20 @@ async function migrateAgentTurnSessionActor(context) {
215239
result.missing += 1;
216240
}
217241
}
242+
218243
return result;
219244
}
220245

221-
// ../../../../../../private/tmp/0004_agent_turn_session_actor-entry.ts
222-
var migration = {
246+
const migration = {
223247
apiVersion: 1,
224248
async up(context) {
225249
return await migrateAgentTurnSessionActor({
226250
stateAdapter: context.state,
227-
redisStateAdapter: context.redis
228-
? { getClient: () => ({ sendCommand: context.redis.sendCommand }) }
229-
: void 0,
230-
io: { info: context.log },
251+
...(context.redis
252+
? { redisStateAdapter: { getClient: () => context.redis! } }
253+
: {}),
231254
});
232255
},
233-
};
234-
var agent_turn_session_actor_entry_default = migration;
235-
export {
236-
agent_turn_session_actor_entry_default as default,
237-
migrateAgentTurnSessionActor,
238-
};
256+
} satisfies MigrationV1;
257+
258+
export default migration;

0 commit comments

Comments
 (0)