rahman-resources 0.9.2 → 0.12.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.
@@ -0,0 +1,86 @@
1
+ // Type definitions for the migration planner (Phase E of the Slice
2
+ // Composition Compiler). Runtime lives in migration-plan.mjs.
3
+
4
+ import type { SliceContract } from "./contract";
5
+
6
+ /**
7
+ * Structural diff between two versions of a slice's contract.
8
+ *
9
+ * `renamed.tables` only carries entries the planner could pair up via
10
+ * `to.migrationFrom[fromVersion]` heuristics. Anything ambiguous stays
11
+ * split between `added` and `removed`.
12
+ */
13
+ export interface ContractDiff {
14
+ slug: string;
15
+ fromVersion: string;
16
+ toVersion: string;
17
+ added: {
18
+ tables?: string[];
19
+ routes?: string[];
20
+ env?: string[];
21
+ rbac?: string[];
22
+ };
23
+ removed: {
24
+ tables?: string[];
25
+ routes?: string[];
26
+ env?: string[];
27
+ rbac?: string[];
28
+ };
29
+ renamed: {
30
+ tables?: { from: string; to: string }[];
31
+ };
32
+ }
33
+
34
+ /** Discriminator on the concrete operation a migration step performs. */
35
+ export type MigrationStepKind =
36
+ | "convex-schema-add-table"
37
+ | "convex-schema-drop-table"
38
+ | "convex-schema-rename-table"
39
+ | "env-add"
40
+ | "env-remove"
41
+ | "rbac-add-permission"
42
+ | "rbac-remove-permission"
43
+ | "route-add"
44
+ | "route-remove";
45
+
46
+ /** A single, atomic migration step the operator must perform / approve. */
47
+ export interface MigrationStep {
48
+ /** Stable id, e.g. "M001-add-table-doku_orders". */
49
+ id: string;
50
+ kind: MigrationStepKind;
51
+ description: string;
52
+ /** True when running the step in reverse can fully undo it. */
53
+ reversible: boolean;
54
+ risk: "low" | "medium" | "high";
55
+ /** Pre-rendered artifacts the operator can paste / write. */
56
+ artifacts: {
57
+ /** Snippet to add to convex/features/<slug>/schema.ts. */
58
+ convexSchema?: string;
59
+ /** Full file body for convex/migrations/<id>.ts. */
60
+ convexMigration?: string;
61
+ /** Line to append to .env.example. */
62
+ envExample?: string;
63
+ /** Patch instructions for the project's RBAC permissions config. */
64
+ rbacPatch?: string;
65
+ /** Free-form operator note (e.g. "Convex has no rename-in-place"). */
66
+ note?: string;
67
+ };
68
+ }
69
+
70
+ /** Final planner output. */
71
+ export interface MigrationPlan {
72
+ slug: string;
73
+ fromVersion: string;
74
+ toVersion: string;
75
+ steps: MigrationStep[];
76
+ summary: {
77
+ totalSteps: number;
78
+ highRisk: number;
79
+ irreversible: number;
80
+ };
81
+ /** Free-text concerns surfaced by the planner. */
82
+ warnings: string[];
83
+ }
84
+
85
+ export function diffContracts(from: SliceContract, to: SliceContract): ContractDiff;
86
+ export function planMigration(diff: ContractDiff): MigrationPlan;
@@ -0,0 +1,414 @@
1
+ // migration-plan.mjs — Phase E of the Slice Composition Compiler.
2
+ //
3
+ // Given two versions of a slice contract (or just one when synthesising a
4
+ // fresh migration), produce a structured diff + a concrete migration plan
5
+ // with risk + reversibility scoring and pre-rendered artifacts the operator
6
+ // can paste into Convex schema files, .env.example, or the project's RBAC
7
+ // config.
8
+ //
9
+ // Public API + types live in migration-plan.d.ts.
10
+ //
11
+ // Runtime contract:
12
+ // - `diffContracts(from, to)` is pure. No fs, no env. Returns a fresh
13
+ // {@link ContractDiff}.
14
+ // - `planMigration(diff)` is pure. Returns a fresh {@link MigrationPlan}.
15
+ // - The CLI dispatcher (../bin/migrate.mjs) is the only side-effecting
16
+ // consumer — it loads contracts via git + writes files into
17
+ // `convex/migrations/`.
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Public — diffContracts
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /**
24
+ * Compute the structural diff between two versions of a slice contract.
25
+ *
26
+ * Rename detection runs only when `to.migrationFrom?.[from.version]` is set.
27
+ * When the marker is present the planner tries to pair entries that exist
28
+ * only on one side via a stable index match — same-position entries become
29
+ * a renamed pair, the rest stay as added/removed. This conservative pairing
30
+ * keeps the output deterministic without parsing the marker string itself.
31
+ *
32
+ * @param {import("./contract").SliceContract} from
33
+ * @param {import("./contract").SliceContract} to
34
+ * @returns {import("./migration-plan").ContractDiff}
35
+ */
36
+ export function diffContracts(from, to) {
37
+ if (!from || typeof from !== "object") {
38
+ throw new Error("diffContracts: `from` contract is required");
39
+ }
40
+ if (!to || typeof to !== "object") {
41
+ throw new Error("diffContracts: `to` contract is required");
42
+ }
43
+ if (from.id !== to.id) {
44
+ throw new Error(
45
+ `diffContracts: contract ids differ — "${from.id}" vs "${to.id}"`,
46
+ );
47
+ }
48
+
49
+ const fromTables = unique(from.provides?.tables ?? []);
50
+ const toTables = unique(to.provides?.tables ?? []);
51
+ const fromRoutes = unique(from.provides?.routes ?? []);
52
+ const toRoutes = unique(to.provides?.routes ?? []);
53
+ const fromEnv = unique(from.requires?.env ?? []);
54
+ const toEnv = unique(to.requires?.env ?? []);
55
+ const fromRbac = unique(from.requires?.rbac ?? []);
56
+ const toRbac = unique(to.requires?.rbac ?? []);
57
+
58
+ /** @type {string[]} */
59
+ let addedTables = diffArr(toTables, fromTables);
60
+ /** @type {string[]} */
61
+ let removedTables = diffArr(fromTables, toTables);
62
+ /** @type {{ from: string; to: string }[]} */
63
+ const renamedTables = [];
64
+
65
+ // Rename detection — only when migrationFrom[from.version] is set.
66
+ const hasRenameMarker =
67
+ to.migrationFrom &&
68
+ typeof to.migrationFrom === "object" &&
69
+ typeof to.migrationFrom[from.version] === "string";
70
+
71
+ if (hasRenameMarker && removedTables.length > 0 && addedTables.length > 0) {
72
+ // Pair by position in the original `provides.tables` arrays — covers the
73
+ // most common case ("rename every table at once") without trying to
74
+ // parse the marker string. Anything unpaired stays in added/removed.
75
+ const removedInOrder = fromTables.filter((t) => removedTables.includes(t));
76
+ const addedInOrder = toTables.filter((t) => addedTables.includes(t));
77
+ const pairs = Math.min(removedInOrder.length, addedInOrder.length);
78
+ for (let i = 0; i < pairs; i++) {
79
+ renamedTables.push({ from: removedInOrder[i], to: addedInOrder[i] });
80
+ }
81
+ const pairedFrom = new Set(renamedTables.map((r) => r.from));
82
+ const pairedTo = new Set(renamedTables.map((r) => r.to));
83
+ removedTables = removedTables.filter((t) => !pairedFrom.has(t));
84
+ addedTables = addedTables.filter((t) => !pairedTo.has(t));
85
+ }
86
+
87
+ /** @type {import("./migration-plan").ContractDiff} */
88
+ const diff = {
89
+ slug: from.id,
90
+ fromVersion: from.version,
91
+ toVersion: to.version,
92
+ added: pruneEmpty({
93
+ tables: addedTables,
94
+ routes: diffArr(toRoutes, fromRoutes),
95
+ env: diffArr(toEnv, fromEnv),
96
+ rbac: diffArr(toRbac, fromRbac),
97
+ }),
98
+ removed: pruneEmpty({
99
+ tables: removedTables,
100
+ routes: diffArr(fromRoutes, toRoutes),
101
+ env: diffArr(fromEnv, toEnv),
102
+ rbac: diffArr(fromRbac, toRbac),
103
+ }),
104
+ renamed: pruneEmpty({
105
+ tables: renamedTables,
106
+ }),
107
+ };
108
+
109
+ return diff;
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Public — planMigration
114
+ // ---------------------------------------------------------------------------
115
+
116
+ /**
117
+ * Turn a {@link ContractDiff} into a concrete, risk-scored migration plan.
118
+ *
119
+ * Step id format: `M{NNN}-{kind-suffix}-{name}` — stable + sortable so the
120
+ * CLI can write files in execution order. NNN is zero-padded to 3 digits.
121
+ *
122
+ * @param {import("./migration-plan").ContractDiff} diff
123
+ * @returns {import("./migration-plan").MigrationPlan}
124
+ */
125
+ export function planMigration(diff) {
126
+ if (!diff || typeof diff !== "object" || !diff.slug) {
127
+ throw new Error("planMigration: diff with slug is required");
128
+ }
129
+
130
+ /** @type {import("./migration-plan").MigrationStep[]} */
131
+ const steps = [];
132
+ /** @type {string[]} */
133
+ const warnings = [];
134
+ let counter = 1;
135
+
136
+ const nextId = (suffix) => {
137
+ const id = `M${String(counter).padStart(3, "0")}-${suffix}`;
138
+ counter += 1;
139
+ return id;
140
+ };
141
+
142
+ // 1) Renames first (they precede plain adds, since rename is "move data")
143
+ for (const pair of diff.renamed.tables ?? []) {
144
+ steps.push({
145
+ id: nextId(`rename-table-${pair.from}-to-${pair.to}`),
146
+ kind: "convex-schema-rename-table",
147
+ description: `Rename Convex table "${pair.from}" → "${pair.to}". Convex has no in-place rename — data must be copied via a migration mutation.`,
148
+ reversible: true,
149
+ risk: "medium",
150
+ artifacts: {
151
+ convexSchema: renderRenameSchemaSnippet(diff.slug, pair.from, pair.to),
152
+ convexMigration: renderRenameMigration(diff.slug, pair.from, pair.to),
153
+ note:
154
+ 'Convex does not support table rename in-place; data copy required. Run the migration as a one-shot mutation, then drop the old table once verified.',
155
+ },
156
+ });
157
+ }
158
+
159
+ // 2) Adds — tables, env, rbac, routes (info-only).
160
+ for (const name of diff.added.tables ?? []) {
161
+ steps.push({
162
+ id: nextId(`add-table-${name}`),
163
+ kind: "convex-schema-add-table",
164
+ description: `Add new Convex table "${name}" to convex/features/${diff.slug}/schema.ts.`,
165
+ reversible: true,
166
+ risk: "low",
167
+ artifacts: {
168
+ convexSchema: renderAddTableSnippet(diff.slug, name),
169
+ note: `Spread \`${camelCase(diff.slug)}Tables\` into convex/schema.ts so the new table is registered with the deployment.`,
170
+ },
171
+ });
172
+ }
173
+ for (const name of diff.added.env ?? []) {
174
+ steps.push({
175
+ id: nextId(`env-add-${name}`),
176
+ kind: "env-add",
177
+ description: `Declare required env var "${name}".`,
178
+ reversible: true,
179
+ risk: "low",
180
+ artifacts: {
181
+ envExample: `${name}= # set in .env.local before deploy`,
182
+ note: `Append to .env.example so consumers see the requirement. Set the real value in .env.local.`,
183
+ },
184
+ });
185
+ }
186
+ for (const perm of diff.added.rbac ?? []) {
187
+ steps.push({
188
+ id: nextId(`rbac-add-${perm}`),
189
+ kind: "rbac-add-permission",
190
+ description: `Add RBAC permission "${perm}" to the project's permissions config.`,
191
+ reversible: true,
192
+ risk: "low",
193
+ artifacts: {
194
+ rbacPatch: renderRbacAddSnippet(perm),
195
+ note: `Add the permission to convex/workspace/permissions.ts (or the project equivalent), then grant it to the relevant role presets.`,
196
+ },
197
+ });
198
+ }
199
+ for (const route of diff.added.routes ?? []) {
200
+ steps.push({
201
+ id: nextId(`route-add-${slugifyForId(route)}`),
202
+ kind: "route-add",
203
+ description: `Slice mounts new route "${route}" — wire it up in the consumer's app router.`,
204
+ reversible: true,
205
+ risk: "low",
206
+ artifacts: {
207
+ note: `Route is provided by the slice; this step is informational. Verify no consumer-side route already collides.`,
208
+ },
209
+ });
210
+ }
211
+
212
+ // 3) Removes — tables (high risk), env / rbac / routes (low-medium).
213
+ for (const name of diff.removed.tables ?? []) {
214
+ steps.push({
215
+ id: nextId(`drop-table-${name}`),
216
+ kind: "convex-schema-drop-table",
217
+ description: `Drop Convex table "${name}". DATA LOSS — back up before running.`,
218
+ reversible: false,
219
+ risk: "high",
220
+ artifacts: {
221
+ convexMigration: renderDropMigration(diff.slug, name),
222
+ note:
223
+ 'Backup data before drop. Consider rename-then-deprecate instead of a hard drop — that path is reversible.',
224
+ },
225
+ });
226
+ warnings.push(
227
+ `Drop of "${name}" is irreversible. Backup data before drop. Consider rename-then-deprecate.`,
228
+ );
229
+ }
230
+ for (const name of diff.removed.env ?? []) {
231
+ steps.push({
232
+ id: nextId(`env-remove-${name}`),
233
+ kind: "env-remove",
234
+ description: `Env var "${name}" is no longer required by the slice — remove from .env.example.`,
235
+ reversible: true,
236
+ risk: "low",
237
+ artifacts: {
238
+ note: `Removing an env declaration is safe; the runtime simply ignores it. Drop the line from .env.example.`,
239
+ },
240
+ });
241
+ }
242
+ for (const perm of diff.removed.rbac ?? []) {
243
+ steps.push({
244
+ id: nextId(`rbac-remove-${perm}`),
245
+ kind: "rbac-remove-permission",
246
+ description: `RBAC permission "${perm}" is no longer required — consider deprecating in the project's permissions config.`,
247
+ reversible: true,
248
+ risk: "medium",
249
+ artifacts: {
250
+ rbacPatch: renderRbacRemoveSnippet(perm),
251
+ note: `Removing a permission may strand roles that still reference it. Audit role presets before deleting.`,
252
+ },
253
+ });
254
+ }
255
+ for (const route of diff.removed.routes ?? []) {
256
+ steps.push({
257
+ id: nextId(`route-remove-${slugifyForId(route)}`),
258
+ kind: "route-remove",
259
+ description: `Slice no longer provides route "${route}" — remove dangling links in the consumer.`,
260
+ reversible: true,
261
+ risk: "low",
262
+ artifacts: {
263
+ note: `Route is no longer mounted by the slice; this step is informational. Audit navigation + sitemap entries.`,
264
+ },
265
+ });
266
+ }
267
+
268
+ const highRisk = steps.filter((s) => s.risk === "high").length;
269
+ const irreversible = steps.filter((s) => !s.reversible).length;
270
+
271
+ return {
272
+ slug: diff.slug,
273
+ fromVersion: diff.fromVersion,
274
+ toVersion: diff.toVersion,
275
+ steps,
276
+ summary: {
277
+ totalSteps: steps.length,
278
+ highRisk,
279
+ irreversible,
280
+ },
281
+ warnings,
282
+ };
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Internal — artifact renderers
287
+ // ---------------------------------------------------------------------------
288
+
289
+ function renderAddTableSnippet(slug, table) {
290
+ const obj = `${camelCase(slug)}Tables`;
291
+ return [
292
+ `// convex/features/${slug}/schema.ts`,
293
+ `// Append the new table to the existing \`${obj}\` export:`,
294
+ `//`,
295
+ `// export const ${obj} = {`,
296
+ `// ...existing,`,
297
+ ` ${table}: defineTable({`,
298
+ ` // TODO: declare fields for ${table}`,
299
+ ` createdAt: v.number(),`,
300
+ ` }),`,
301
+ `// };`,
302
+ ].join("\n");
303
+ }
304
+
305
+ function renderRenameSchemaSnippet(slug, fromName, toName) {
306
+ const obj = `${camelCase(slug)}Tables`;
307
+ return [
308
+ `// convex/features/${slug}/schema.ts`,
309
+ `// Rename "${fromName}" → "${toName}" inside \`${obj}\`:`,
310
+ `//`,
311
+ `// export const ${obj} = {`,
312
+ `// // OLD: ${fromName}: defineTable({...})`,
313
+ ` ${toName}: defineTable({`,
314
+ ` // Carry over the prior shape from ${fromName}.`,
315
+ ` }),`,
316
+ `// };`,
317
+ ].join("\n");
318
+ }
319
+
320
+ function renderRenameMigration(slug, fromName, toName) {
321
+ return [
322
+ `// convex/migrations/rename-${fromName}-to-${toName}.ts`,
323
+ `// One-shot data copy — Convex has no in-place table rename.`,
324
+ ``,
325
+ `import { internalMutation } from "../_generated/server";`,
326
+ ``,
327
+ `export default internalMutation({`,
328
+ ` args: {},`,
329
+ ` handler: async (ctx) => {`,
330
+ ` // 1. Copy every row from the old table into the new one.`,
331
+ ` const rows = await ctx.db.query("${fromName}").collect();`,
332
+ ` for (const row of rows) {`,
333
+ ` const { _id, _creationTime, ...rest } = row;`,
334
+ ` await ctx.db.insert("${toName}", rest);`,
335
+ ` }`,
336
+ ` // 2. Once verified, drop the old table in a follow-up migration.`,
337
+ ` // (Leaving the drop separate keeps this step reversible.)`,
338
+ ` return { copied: rows.length };`,
339
+ ` },`,
340
+ `});`,
341
+ `// slug: ${slug}`,
342
+ ].join("\n");
343
+ }
344
+
345
+ function renderDropMigration(slug, table) {
346
+ return [
347
+ `// convex/migrations/drop-${table}.ts`,
348
+ `// IRREVERSIBLE — backup ${table} before running. Slice: ${slug}.`,
349
+ ``,
350
+ `import { internalMutation } from "../_generated/server";`,
351
+ ``,
352
+ `export default internalMutation({`,
353
+ ` args: {},`,
354
+ ` handler: async (ctx) => {`,
355
+ ` const rows = await ctx.db.query("${table}").collect();`,
356
+ ` for (const row of rows) {`,
357
+ ` await ctx.db.delete(row._id);`,
358
+ ` }`,
359
+ ` return { deleted: rows.length };`,
360
+ ` },`,
361
+ `});`,
362
+ ].join("\n");
363
+ }
364
+
365
+ function renderRbacAddSnippet(perm) {
366
+ return [
367
+ `// convex/workspace/permissions.ts (or project equivalent)`,
368
+ `// Append "${perm}" to the permission catalog + grant to relevant roles:`,
369
+ `//`,
370
+ `// export const PERMISSIONS = [`,
371
+ `// ...existing,`,
372
+ ` "${perm}",`,
373
+ `// ] as const;`,
374
+ ].join("\n");
375
+ }
376
+
377
+ function renderRbacRemoveSnippet(perm) {
378
+ return [
379
+ `// convex/workspace/permissions.ts (or project equivalent)`,
380
+ `// Remove "${perm}" once no role preset still references it.`,
381
+ ].join("\n");
382
+ }
383
+
384
+ // ---------------------------------------------------------------------------
385
+ // Internal — helpers
386
+ // ---------------------------------------------------------------------------
387
+
388
+ function diffArr(a, b) {
389
+ const setB = new Set(b);
390
+ return a.filter((x) => !setB.has(x));
391
+ }
392
+
393
+ function unique(a) {
394
+ return Array.from(new Set(a));
395
+ }
396
+
397
+ function pruneEmpty(obj) {
398
+ /** @type {Record<string, unknown>} */
399
+ const out = {};
400
+ for (const [k, v] of Object.entries(obj)) {
401
+ if (Array.isArray(v) && v.length === 0) continue;
402
+ if (v == null) continue;
403
+ out[k] = v;
404
+ }
405
+ return out;
406
+ }
407
+
408
+ function camelCase(slug) {
409
+ return String(slug).replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
410
+ }
411
+
412
+ function slugifyForId(s) {
413
+ return String(s).replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase() || "x";
414
+ }