Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
0fe8d29
Add mongo-migration-authoring project spec and design reference
wmadden Apr 15, 2026
199e307
Add Mongo migration factory functions
wmadden Apr 15, 2026
59e73cb
Add target-agnostic Migration base class
wmadden Apr 15, 2026
b3cbc56
Add Mongo-specific Migration alias
wmadden Apr 15, 2026
16bd779
Add validatedCollection strategy and end-to-end tests
wmadden Apr 15, 2026
a65957f
Document migration authoring in target-mongo README
wmadden Apr 15, 2026
a49285c
Add migration.json output to Migration base class
wmadden Apr 15, 2026
d12b3e6
Move MongoMigration from target-mongo to family-mongo
wmadden Apr 15, 2026
f2d6276
Add E2E tests for migration authoring against real MongoDB
wmadden Apr 15, 2026
e1307cd
Update READMEs to reflect migration authoring entrypoint move
wmadden Apr 15, 2026
f7632fe
Add hand-authored TS migration to mongo-demo
wmadden Apr 15, 2026
b4d7f9d
Add hand-authored TS migration to retail-store
wmadden Apr 15, 2026
a797692
fix(mongo-migration): add override modifier to Migration subclass met…
wmadden Apr 15, 2026
04ef6e8
feat(migration): print confirmation after writing migration files
wmadden Apr 15, 2026
b14e769
docs: add specs for migration subsystem refactor and planner dual output
wmadden Apr 15, 2026
07fb8bd
Add plan.md
wmadden Apr 15, 2026
bc3daf7
fix(mongo-migration): address code review findings F01-F06
wmadden Apr 15, 2026
987f466
docs: update spec to reflect implementation decisions
wmadden Apr 15, 2026
2a174b7
refactor(mongo-migration): replace collMod with setValidation, merge …
wmadden Apr 15, 2026
be9e596
fix(migration): improve Migration.run() API and add describe() valida…
wmadden Apr 15, 2026
3c02233
fix(demo-tests): use static imports and load artifacts from disk
wmadden Apr 15, 2026
1a5a527
refactor(migration): pass class constructor to Migration.run() instea…
wmadden Apr 15, 2026
7096833
fix(target-mongo): narrow setValidation option types to match CollMod…
wmadden Apr 15, 2026
41626d3
fix(integration): fix typecheck errors in migration-authoring E2E test
wmadden Apr 15, 2026
d5c7a78
fix(examples): fix typecheck errors in mongo-demo and retail-store tests
wmadden Apr 15, 2026
67327c5
docs(mongo-migration-authoring): add data migrations spec and plan
wmadden Apr 15, 2026
00d2d8b
fix(migration-tools): increase subprocess test timeout for CI
wmadden Apr 15, 2026
ae9bf64
docs(mongo-migration-authoring): rewrite data migrations spec for cla…
wmadden Apr 15, 2026
e8830a3
docs(data-migrations): use typed filter proxy in check example
wmadden Apr 15, 2026
d42276f
fix(target-mongo): preserve explicit unique:false in createIndex factory
wmadden Apr 15, 2026
4ecabe1
fix(integration): use Promise.allSettled in afterAll teardown
wmadden Apr 15, 2026
eabd838
Merge branch 'main' into mongo-manual-migrations
wmadden Apr 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"migrationId": null,
"from": "sha256:358522152ebe3ca9db3d573471c656778c1845f4cdd424caf06632352b9772fe",
"to": "sha256:358522152ebe3ca9db3d573471c656778c1845f4cdd424caf06632352b9772fe",
"kind": "regular",
"labels": ["add-posts-author-index"],
"createdAt": "2026-04-15T17:17:30.570Z"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Migration } from '@prisma-next/family-mongo/migration';
import { createIndex } from '@prisma-next/target-mongo/migration';

class AddPostsAuthorIndex extends Migration {
override describe() {
return {
from: 'sha256:358522152ebe3ca9db3d573471c656778c1845f4cdd424caf06632352b9772fe',
to: 'sha256:358522152ebe3ca9db3d573471c656778c1845f4cdd424caf06632352b9772fe',
labels: ['add-posts-author-index'],
};
}

override plan() {
return [
createIndex('posts', [{ field: 'authorId', direction: 1 }]),
createIndex('posts', [
{ field: 'createdAt', direction: -1 },
{ field: 'authorId', direction: 1 },
]),
];
}
}

export default AddPostsAuthorIndex;
Migration.run(import.meta.url, AddPostsAuthorIndex);
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
[
{
"id": "index.posts.create(authorId:1)",
"label": "Create index on posts (authorId:1)",
"operationClass": "additive",
"precheck": [
{
"description": "index does not already exist on posts",
"source": {
"kind": "listIndexes",
"collection": "posts"
},
"filter": {
"kind": "field",
"field": "key",
"op": "$eq",
"value": {
"authorId": 1
}
},
"expect": "notExists"
}
],
"execute": [
{
"description": "create index on posts",
"command": {
"kind": "createIndex",
"collection": "posts",
"keys": [
{
"field": "authorId",
"direction": 1
}
],
"name": "authorId_1"
}
}
],
"postcheck": [
{
"description": "index exists on posts",
"source": {
"kind": "listIndexes",
"collection": "posts"
},
"filter": {
"kind": "field",
"field": "key",
"op": "$eq",
"value": {
"authorId": 1
}
},
"expect": "exists"
}
]
},
{
"id": "index.posts.create(createdAt:-1,authorId:1)",
"label": "Create index on posts (createdAt:-1, authorId:1)",
"operationClass": "additive",
"precheck": [
{
"description": "index does not already exist on posts",
"source": {
"kind": "listIndexes",
"collection": "posts"
},
"filter": {
"kind": "field",
"field": "key",
"op": "$eq",
"value": {
"createdAt": -1,
"authorId": 1
}
},
"expect": "notExists"
}
],
"execute": [
{
"description": "create index on posts",
"command": {
"kind": "createIndex",
"collection": "posts",
"keys": [
{
"field": "createdAt",
"direction": -1
},
{
"field": "authorId",
"direction": 1
}
],
"name": "createdAt_-1_authorId_1"
}
}
],
"postcheck": [
{
"description": "index exists on posts",
"source": {
"kind": "listIndexes",
"collection": "posts"
},
"filter": {
"kind": "field",
"field": "key",
"op": "$eq",
"value": {
"createdAt": -1,
"authorId": 1
}
},
"expect": "exists"
}
]
}
]
4 changes: 3 additions & 1 deletion examples/mongo-demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@
},
"dependencies": {
"@prisma-next/adapter-mongo": "workspace:*",
"@prisma-next/target-mongo": "workspace:*",
"@prisma-next/contract": "workspace:*",
"@prisma-next/middleware-telemetry": "workspace:*",
"@prisma-next/driver-mongo": "workspace:*",
"@prisma-next/middleware-telemetry": "workspace:*",
"@prisma-next/mongo-contract": "workspace:*",
"@prisma-next/mongo-orm": "workspace:*",
"@prisma-next/mongo-pipeline-builder": "workspace:*",
Expand All @@ -40,6 +41,7 @@
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react-swc": "^4.2.3",
"mongodb-memory-server": "catalog:",
"pathe": "^2.0.3",
"tsx": "^4.19.2",
"typescript": "catalog:",
"vite": "catalog:",
Expand Down
121 changes: 121 additions & 0 deletions examples/mongo-demo/test/manual-migration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { readFileSync } from 'node:fs';
import { deserializeMongoOps, MongoMigrationRunner } from '@prisma-next/adapter-mongo/control';
import mongoControlDriver from '@prisma-next/driver-mongo/control';
import { timeouts } from '@prisma-next/test-utils';
import { type Db, MongoClient } from 'mongodb';
import { MongoMemoryReplSet } from 'mongodb-memory-server';
import { resolve } from 'pathe';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import AddPostsAuthorIndex from '../migrations/20260415_add-posts-author-index/migration';

const ALL_POLICY = {
allowedOperationClasses: ['additive', 'widening', 'destructive'] as const,
};

const migrationDir = resolve(import.meta.dirname, '../migrations/20260415_add-posts-author-index');

describe(
'hand-authored migration (20260415_add-posts-author-index)',
{ timeout: timeouts.spinUpMongoMemoryServer },
() => {
let replSet: MongoMemoryReplSet;
let client: MongoClient;
let db: Db;
const dbName = 'manual_migration_test';

beforeAll(async () => {
replSet = await MongoMemoryReplSet.create({
instanceOpts: [
{ launchTimeout: timeouts.spinUpMongoMemoryServer, storageEngine: 'wiredTiger' },
],
replSet: { count: 1, storageEngine: 'wiredTiger' },
});
client = new MongoClient(replSet.getUri());
await client.connect();
db = client.db(dbName);
}, timeouts.spinUpMongoMemoryServer);

beforeEach(async () => {
await db.dropDatabase();
});

afterAll(async () => {
try {
await client?.close();
await replSet?.stop();
} catch {
// ignore cleanup errors
}
}, timeouts.spinUpMongoMemoryServer);

it('migration class can be imported and plan() called directly', () => {
const instance = new AddPostsAuthorIndex();
const ops = instance.plan();
expect(ops).toHaveLength(2);
expect(ops[0]!.id).toBe('index.posts.create(authorId:1)');
expect(ops[1]!.id).toBe('index.posts.create(createdAt:-1,authorId:1)');
});

it('migration.json has expected structure', () => {
const manifest = JSON.parse(readFileSync(resolve(migrationDir, 'migration.json'), 'utf-8'));

expect(manifest.migrationId).toBeNull();
expect(manifest.kind).toBe('regular');
expect(manifest.labels).toEqual(['add-posts-author-index']);
expect(manifest.from).toMatch(/^sha256:/);
expect(manifest.to).toMatch(/^sha256:/);
expect(manifest.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});

it('ops.json deserializes and applies against real MongoDB', async () => {
await db.createCollection('posts');

const opsJson = readFileSync(resolve(migrationDir, 'ops.json'), 'utf-8');
const ops = deserializeMongoOps(JSON.parse(opsJson));
expect(ops).toHaveLength(2);

const controlDriver = await mongoControlDriver.create(replSet.getUri(dbName));
try {
const runner = new MongoMigrationRunner();
const result = await runner.execute({
plan: {
targetId: 'mongo',
destination: {
storageHash:
'sha256:358522152ebe3ca9db3d573471c656778c1845f4cdd424caf06632352b9772fe',
},
operations: JSON.parse(opsJson),
},
driver: controlDriver,
destinationContract: {},
policy: ALL_POLICY,
frameworkComponents: [],
Comment thread
wmadden marked this conversation as resolved.
});

expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.operationsExecuted).toBe(2);

const indexes = await db.collection('posts').listIndexes().toArray();

const authorIdIndex = indexes.find(
(idx) =>
idx['key'] &&
(idx['key'] as Record<string, number>)['authorId'] === 1 &&
!('createdAt' in (idx['key'] as Record<string, number>)),
);
expect(authorIdIndex).toBeDefined();

const compoundIndex = indexes.find(
(idx) =>
idx['key'] &&
(idx['key'] as Record<string, number>)['createdAt'] === -1 &&
(idx['key'] as Record<string, number>)['authorId'] === 1,
);
expect(compoundIndex).toBeDefined();
} finally {
await controlDriver.close();
}
});
},
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"migrationId": null,
"from": "sha256:e5cfc21670435e53a4af14a665d61d8ba716d5e2e67b63c1443affdcad86985d",
"to": "sha256:e5cfc21670435e53a4af14a665d61d8ba716d5e2e67b63c1443affdcad86985d",
"kind": "regular",
"labels": ["add-product-validation"],
"createdAt": "2026-04-15T18:46:18.776Z"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Migration } from '@prisma-next/family-mongo/migration';
import { createIndex, setValidation } from '@prisma-next/target-mongo/migration';

class AddProductValidation extends Migration {
override describe() {
return {
from: 'sha256:e5cfc21670435e53a4af14a665d61d8ba716d5e2e67b63c1443affdcad86985d',
to: 'sha256:e5cfc21670435e53a4af14a665d61d8ba716d5e2e67b63c1443affdcad86985d',
labels: ['add-product-validation'],
};
}

override plan() {
return [
setValidation(
'products',
{
bsonType: 'object',
required: ['name', 'price', 'category'],
properties: {
name: { bsonType: 'string' },
price: { bsonType: 'number', minimum: 0 },
category: { bsonType: 'string' },
},
},
{ validationLevel: 'moderate', validationAction: 'warn' },
),
createIndex('products', [
{ field: 'category', direction: 1 },
{ field: 'price', direction: 1 },
]),
];
}
}

export default AddProductValidation;
Migration.run(import.meta.url, AddProductValidation);
Loading
Loading