mongoose-to-prisma Skill
Purpose
Guide the developer through migrating one Mongoose model file to use Prisma extensions, following GROWI's established migration patterns. Human approves each step before proceeding.
Invoke with the path to a Mongoose model file:
/mongoose-to-prisma apps/app/src/server/models/tag.ts
If no path is given, ask for one before starting.
Background: GROWI Migration Strategy
GROWI migrates Mongoose → Prisma incrementally, one model at a time:
- Mongoose is kept for collection and index creation until ALL models have migrated (then
prisma db pushtakes over). This matters because Mongoose creates a model's indexes automatically on connect (autoIndex, enabled by default) — as long as the Mongoose schema file stays in the repo, itsindex/unique/sparsedeclarations keep being applied with no extra work.schema.prisma's own@@index/@@uniqueattributes are inert until that eventualprisma db push; they do not create anything today.- If a model's Mongoose file is deleted instead of kept (deviating from the standard flow above — e.g. because the collection is new and has never been read/written through Mongoose, as decided case-by-case in PR review),
autoIndexno longer runs for it. All indexes that schema used to declare (including anyuniqueconstraint) must be created explicitly via amigrate-mongomigration using the native MongoDB driver (collection.createIndex(...)), or they silently stop existing. Seeapps/app/src/migrations/20260619120000-add-unique-key-index-to-auditlog-es-sync-collections.jsandapps/app/src/migrations/20260901064500-add-indexes-to-pagelinks.jsfor precedent. Flag this explicitly to the developer before Step 6 if the model file is going to be removed rather than kept.
- If a model's Mongoose file is deleted instead of kept (deviating from the standard flow above — e.g. because the collection is new and has never been read/written through Mongoose, as decided case-by-case in PR review),
- Prisma extensions (
Prisma.defineExtension) replace Mongoose statics and instance methods - No breaking API changes — frontend is out of scope; method signatures must remain backward-compatible
_idand__vbackward-compat fields are provided bycreatePrisma()inapps/app/src/utils/prisma.tsas computed fields; callers accessingdoc._idordoc.__vrequire no changes- Prisma does not support column names beginning with
_, so they are declared asid/vwith@map("_id")/@map("__v")aliases inschema.prisma
Key reference files (read these at the start of each invocation to get current patterns):
apps/app/src/server/models/external-account.ts— TS model: Prisma extension added in the same file after the Mongoose schema blockapps/app/src/server/models/user/index.prisma.ts— JS model: separate.prisma.tsfile because the base model is.jsapps/app/prisma/schema.prisma— Prisma schema for MongoDBapps/app/src/utils/prisma.ts—createPrisma()with_id/__vcomputed fields andvauto-increment on every update
Step 0: Startup Summary
Before Step 1, read the target file and apps/app/prisma/schema.prisma, then print:
Target: <path>
Collection: <collection name (lowercase plural from schema or inferred)>
Schema fields: <field list>
Statics: <method names>
Virtuals: <virtual names, or none>
TypeScript interfaces: <interfaces found>
Plugins: <mongoose plugins used>
Hooks: <pre/post hooks if any, else none>
Factory pattern: <yes/no>
schema.prisma: ✅ model entry found / ❌ not found
Already partially migrated: ✅ YES (Prisma extension exists at line N) / ❌ NO
Estimated size: small / medium / large
Ready to start?
Wait for confirmation before proceeding.
Step 1: Analysis
Read the target Mongoose model file in full and extract:
- Schema fields — name, type, required/optional, unique, index, sparse
_id/__vpresence — check the Schema options argument for{ _id: false }and/or{ versionKey: false }. Both default to enabled in Mongoose when unset, but either can be individually disabled — note which of_id/__vactually exist on this model (could be both, either one, or neither)- Statics — each method name and TypeScript signature
- Instance methods — each method name and signature (if any)
- Virtuals — each
schema.virtual('name').get(fn)(and.set(fn)if present): the virtual's name, the getter's return type, and every schema field it reads inside the getter body (these become theneedsfor its Prismaresultcompute alias in Step 3) - Plugins —
mongoose-paginate-v2,mongoose-unique-validator, others - Relations — fields with
ref: 'Model', their names. Note: Mongoose's lack ofrequired: trueon a ref field is not evidence that the relation should beoptionalin Prisma, nor the reverse — Mongoose'srefhas no referential-integrity enforcement at all (it only tells.populate()which model to look up), so the schema alone cannot tell you whether the target can legitimately be missing. Defer the required/optional decision to Step 2.7. - Factory pattern — does the file export
const factory = (crowi: Crowi) => { ... }or similar? - Hooks — any
schema.pre()/schema.post()calls? - Already partially migrated? — is a
Prisma.defineExtensionalready present? - Callers — run all four (alias path AND relative path, both extensions):
grep -r "from '~/server/models/<model-name>'" apps/app/src --include="*.ts" -l
grep -r "from '~/server/models/<model-name>'" apps/app/src --include="*.js" -l
grep -r "from './<model-name>'" apps/app/src --include="*.ts" -l
grep -r "from './<model-name>'" apps/app/src --include="*.js" -l
Present a structured summary of findings.
Ask: "Proceed to Step 2?"
Step 2: schema.prisma Verification
Read apps/app/prisma/schema.prisma and check for the collection entry (use lowercase plural name, e.g. tags for Tag):
Does the collection model exist? If not, it must be added before Step 3.
Do all Mongoose schema fields have a corresponding Prisma field? List any missing fields.
_idand__vmapping: verify the model has:id String @id @default(auto()) @map("_id") @db.ObjectIdv Int @map("__v")
Relations: verify each relation field uses
onDelete: NoAction, onUpdate: NoAction. Mongoose has no referential integrity enforcement; this is the Mongoose-compatible default.Relation optionality — for each relation, decide
Model(required) vsModel?(optional) by investigating the referenced model's deletion lifecycle, not by copying the Mongoose field'srequiredflag (which is rarely set either way and proves nothing — see Step 1.7):- Search for a hard-delete code path that removes documents of the referenced model while documents of this model may still point at them:
grepfor<RefModel>.deleteOne(,.deleteMany(,.findOneAndDelete(,.remove(,prisma.<refModel>.delete(,prisma.<refModel>.deleteMany(, and any raw MongoDB driver calls ($runCommandRaw,db.collection(...).deleteMany) that bypass both ORMs. - If such a path exists, check whether it also cleans up (deletes/nullifies) the referencing documents in the same operation. If cleanup is guaranteed and atomic with the deletion →
requiredis safe. If cleanup is skipped, lazy, eventual, or absent (look for comments like// Leave <Model>s without deleting) → the relation must beoptional, or every read thatincludes it will throwInconsistent query result: Field <x> is required to return data, got null instead.the first time an orphan is hit. - If the referenced model is only ever soft-deleted (a status flag, e.g.
STATUS_DELETED) and no hard-delete path exists, the document persists forever andrequiredis correct. - When genuinely uncertain after searching, default to
optional: on the MongoDB connector,relationModeis always"prisma"(emulated, not a real FK constraint) and is enforced only for writes that go through this Prisma Client — any write via the legacy Mongoose model (still present during incremental migration), a different service, a migration script, or a raw driver call evades it entirely. A wrongly-requiredrelation fails loudly and unpredictably on read (production 500s); a wrongly-optionalone just costs an extra null-check. Resolve the asymmetry towardoptional. - Worked example:
bookmarks.pagehad to becomepages?becausedeleteCompletelyOperation(server/service/page/index.ts) hard-deletes pages via Mongoose'sPage.deleteMany(...)and explicitly comments// Leave bookmarks without deleting.bookmarks.usercorrectly stayedusers(required) because GROWI has no hard-delete path forusers— only aSTATUS_DELETEDsoft-delete — so the referenced row always exists.
- Search for a hard-delete code path that removes documents of the referenced model while documents of this model may still point at them:
Sparse index check: if the Mongoose schema declares
{ sparse: true }on any index, flag it:⚠️ Prisma does not support sparse indexes. Create the index directly via a
migrate-mongomigration using the native MongoDB driver (collection.createIndex({ ... }, { sparse: true })), following the precedent inapps/app/src/migrations/20220411114257-set-sparse-option-to-slack-member-id.js. A sparse index is a database-level property, independent of Mongoose/Prisma — once created this way it keeps applying to every write (Mongoose or Prisma) and survives the eventual full removal of Mongoose. Add a comment inschema.prismanoting the index is sparse and managed by a migration, not by Prisma, so a futureprisma db pushdoesn't attempt to recreate it without the sparse flag.Show the proposed migration file before writing. Wait for approval before proceeding.
If schema.prisma needs changes, show a diff of the proposed additions and wait for approval before writing.
If no changes needed: note "No changes to schema.prisma" and continue.
Ask: "Proceed to Step 3?"
Step 3: Prisma Extension Generation
Generate a Prisma extension that replaces all Mongoose statics (and instance methods if any).
Choose output location:
- Source is a
.tsfile → append the extension at the bottom of the same file after the Mongoose schema block (followexternal-account.tspattern) - Source is a
.jsfile → create a new<model-name>.prisma.tsfile alongside it (followuser/index.prisma.tspattern)
Skeleton pattern (from external-account.ts):
import { Prisma } from '~/generated/prisma/client';
import type { prisma } from '~/utils/prisma';
// import other types as needed
export const extension = Prisma.defineExtension((client) => {
return client.$extends({
result: {
<collection>: {
// include only the fields confirmed present in Step 1 — see note below
// for backward compatibility with mongoose
_id: {
needs: { id: true },
compute(model) {
return model.id;
},
},
// for backward compatibility with mongoose
__v: {
needs: { v: true },
compute(model) {
return model.v;
},
},
},
},
model: {
<collection>: {
async <methodName>(<params>): Promise<ReturnType> {
const context = Prisma.getExtensionContext<typeof prisma.<collection>>(this);
// implementation using context.findMany(), context.count(), etc.
},
},
},
});
});
_id / __v result aliases — only for fields confirmed present in Step 1:
Declare result.<collection>._id and/or result.<collection>.__v in the per-model extension only for the field(s) Step 1 confirmed exist on this model — not blindly for both. Most models have both (Mongoose enables _id and versionKey by default), but a model declared with { _id: false } or { versionKey: false } has only one or neither; do not declare an alias for a field the model never had. Omit the whole result: { <collection>: { ... } } block if neither applies.
This declaration is needed in the per-model extension, not only the global $allModels fallback in apps/app/src/utils/prisma.ts: the global fallback uses // @ts-ignore because $allModels cannot know any specific model's shape, so the computed _id/__v it produces are typed any for every model. Declaring the same field(s) again here — scoped to <collection> — gives them their real type (string/number) because the result extension knows the concrete model shape, and this model-specific declaration takes precedence over the $allModels one once .$extends(<thisModel>Extension) is chained after it in utils/prisma.ts. Skipping this for a field the model actually has leaves it typed any for every caller of this collection.
Virtual fields → result compute aliases:
For each virtual found in Step 1, port the getter's logic into a result.<collection>.<virtualName> field in the same extension, alongside _id/__v. needs must list every schema field the getter body reads; compute runs the same logic against the Prisma model shape:
// Before (Mongoose)
schema.virtual('isDeleted').get(function(this: FooDocument) {
return this.deletedAt != null;
});
// After (Prisma) — inside the same result.<collection> block as _id/__v
result: {
<collection>: {
// ...,
isDeleted: {
needs: { deletedAt: true },
compute(model) {
return model.deletedAt != null;
},
},
},
},
If the getter calls other instance methods or reads fields through populated relations, resolve those dependencies first — needs can only reference scalar fields on the same model, not populated relation documents. If a virtual cannot be expressed as a pure function of the model's own scalar fields (e.g. it queries another collection), flag it and discuss an alternative (a Prisma extension model method instead of a result field) with the developer rather than forcing it into compute.
Once ported here, the original schema.virtual(...) block is deleted in Step 6 — see the cleanup table.
Converting Mongoose statics to Prisma:
Model.find({ ... })→context.findMany({ where: { ... } })Model.findOne({ ... })→context.findUnique({ where: { ... } })when the filter uses a unique field or compound unique index (verifiable fromschema.prisma); otherwisecontext.findFirst({ where: { ... } }). PreferfindUniquewherever possible.Model.countDocuments({ ... })→context.count({ where: { ... } })Model.aggregate([...])→ keep as a raw aggregation viaclient.$runCommandRaw()if complex, or rewrite with Prisma groupByModel.insertMany([...])→context.createMany({ data: [...] })Model.updateMany({ ... }, { ... })→context.updateMany({ where: { ... }, data: { ... } })- ObjectId strings: Prisma uses
Stringfor ObjectId fields; use.toString()when comparing withTypes.ObjectIdvalues from callers
Plugin replacements:
mongoose-paginate-v2→ use thepaginatemodel method already defined on$allModelsinapps/app/src/utils/prisma.ts— do not hand-rollfindMany/count. Call it ascontext.paginate({ where, orderBy, include, select, page, limit }); it returns amongoose-paginate-v2-compatible shape (docs,totalDocs,limit,page,pagingCounter,totalPages,hasNextPage,hasPrevPage,nextPage,prevPage), so callers that destructure those fields from the oldModel.paginate(...)result need no further changes.mongoose-unique-validator→ already handled by@uniqueinschema.prisma; the developer should catch Prisma error codeP2002where unique constraint errors are currently caught.
Hook migration:
If schema.pre()/schema.post() hooks exist, convert to $extends query middleware:
query: {
<collection>: {
async create({ args, query }) {
// pre-save logic
const result = await query(args);
// post-save logic
return result;
},
},
},
Factory pattern:
If the model uses const factory = (crowi: Crowi) => { ... }, the goal is to avoid passing crowi as a closure parameter into Prisma.defineExtension — createPrisma() in apps/app/src/utils/prisma.ts currently takes no arguments, so there is no established pattern for injecting crowi itself into the Prisma client chain.
Before generating code, investigate what the factory actually uses crowi for (typically crowi.events.<event>.emit(...), but verify per model — it may be something else):
- Open the dependency behind
crowi.<path>(e.g. the event class behindcrowi.events.<event>) and read its implementation, not just its usage site in the model file. - Check whether that dependency reads
this.crowi/needs live access to theCrowiinstance anywhere in its own methods, or whether the reference is unused dead weight. - Based on what you find, work out the smallest change that removes the closure parameter while preserving behavior — this varies per model and per dependency, so don't reach for a fixed template. Consider, as starting points, whatever fits the actual finding: hoisting the dependency to a module-level singleton (clean, but only safe if it never needs live
crowistate), a lazily-read accessor for theCrowiinstance (keeps the dependency untouched but still needscrowiavailable somewhere at call time), or some other shape entirely if the investigation turns up something different. - Present the specific approach you worked out — grounded in what step 1–2 found for this model — to the developer, with the reasoning, and wait for their decision before writing any code. Do not pick silently and do not produce a half-working implementation.
Show the full generated extension before writing. Wait for approval.
Ask: "Proceed to Step 4?"
Step 4: Caller Update
For each caller file found in Step 1, make the following changes:
4-1: Replace call sites
Replace every MongooseModel.method() call with the Prisma equivalent.
The Mongoose model is typically imported as a default import and used directly:
// Before (Mongoose)
import BookmarkFolder from '~/server/models/bookmark-folder';
const folder = await BookmarkFolder.createByParameters(params);
After migration, use prisma from ~/utils/prisma:
// After (Prisma)
import { prisma } from '~/utils/prisma';
const folder = await prisma.bookmarkfolders.createByParameters(params);
For each caller file:
- Find every call to the Mongoose model's statics
- Replace with the Prisma extension method on the matching collection
- Update the import: remove the Mongoose model default import, add
import { prisma } from '~/utils/prisma'if not already present
Show diffs for all caller files before writing. Ask: "Apply caller updates?"
4-2: Relation field access
In Mongoose, a relation field (e.g. comment.page) could be either an ObjectId or a populated Document.
In Prisma, these are always split into two fields:
pageId: String— the raw ObjectId stringpage: pages— the populated Document (present only when queried withinclude: { page: true })
For each relation in the model, check callers for how they access the field and list what needs to change. Confirm no change breaks the API contract (no frontend impact).
API response remapping — IMPORTANT:
The frontend still expects the original Mongoose field name (e.g. owner, page, user), not the Prisma-renamed field (ownerId, pageId, userId). Search every API route handler among the callers found in Step 1 (typically server/routes/apiv3/**) for places where a Prisma result is sent via res.apiv3(...) / res.json(...) / a serializer, and remap each renamed relation field back to its original name before responding:
// Before (Mongoose) — field was already named `owner`
return res.apiv3({ bookmarkFolder });
// After (Prisma) — `owner` became `ownerId`; map it back for the response
return res.apiv3({
bookmarkFolder: {
...bookmarkFolder,
owner: bookmarkFolder.ownerId,
},
});
This applies to every relation field that was split in Step 4-2 above (page/pageId, user/userId, owner/ownerId, parent/parentId, etc.), and to nested relations returned via include (e.g. bookmark.page.creatorId → bookmark.page.creator). List every response shape affected and show the diff before writing.
4-3: Type mapping
FooDocumenttype → replace with the Prisma-generatedfoostype from~/generated/prisma/clientFooModeltype → remove; callers now callprisma.<collection>.<method>()directly
_id / __v compatibility: No action needed — these are computed fields in createPrisma(). Callers that access doc._id or doc.__v continue to work without changes.
4-4: __v behavior change — IMPORTANT
Mongoose incremented
__vonly on specific operations (array field modifications via$push,$pull, etc.). Prisma incrementsv(aliased as__v) on everyupdate/updateManycall via the$extendsquery middleware inutils/prisma.ts.
Ask the developer to confirm this behavioral difference is acceptable for this model, or to identify any callers/tests that assert on specific __v values after partial updates.
Ask: "Proceed to Step 5?"
Step 5: Test Review
Search for *.spec.ts and *.integ.ts files related to the target model:
find apps/app/src \( -name "*.spec.ts" -o -name "*.integ.ts" \) | xargs grep -l "<ModelName>\|<collection>" 2>/dev/null
For each test file found:
- List Mongoose-specific constructs (direct
Model.find()calls,new Model(),model.save(), etc.) - Propose updated versions that use
prisma.<collection>.<method>()from the Prisma client - Note the
__vbehavior change from Step 4 — if any test asserts on a specific__vvalue after a partial update (not an array-modifying op), flag it for review
Ask: "Proceed to Step 6?"
Step 6: Mongoose Cleanup
Remove superseded Mongoose code from the model file now that the Prisma extension is in place and callers have been updated.
The classification table below keeps the Mongoose schema block (simplified) specifically so autoIndex keeps creating this model's indexes — see the autoIndex note in Background. If the plan for this model is to delete the schema block/file entirely rather than keep it, stop and confirm with the developer that a migrate-mongo migration will create the indexes instead (see Background) before proceeding.
6-1: Classify every section
Read the file and apply the following rules to every section:
| Section | Action |
|---|---|
interface FooDocument extends Document { ... } |
Remove — Mongoose-specific type, no longer needed |
interface FooModel extends Model<FooDocument> { ... } |
Remove — Mongoose-specific type, no longer needed |
Mongoose schema block (new Schema(...)) |
Keep but simplify: strip type arguments (new Schema<FooDocument, FooModel>(...) → new Schema(...)) and remove the options argument ({ toObject, timestamps, strict, ... }) — Mongoose is only used for schema registration, not serialization |
| TODO comment above schema | Keep (add if missing) |
schema.virtual(...) blocks |
Remove, but only after its logic has been ported to a result.<collection>.<virtualName> compute alias in the Prisma extension (Step 3) — verify the alias exists before deleting |
Mongoose statics (schema.statics.X = ...) |
Remove — replaced by Prisma extension |
getOrCreateModel(...) call |
Keep the call (needed to register the schema), but remove the export default — callers now use Prisma and no longer import this model |
Prisma.defineExtension(...) block |
Keep — target state |
Imports from mongoose |
Remove any that are only used by deleted sections (e.g. Document, Model, Types, HydratedDocument) — keep Schema and the default mongoose import if still referenced by the schema block |
List the exact line ranges to delete and the modifications to make. Ask: "Proceed?"
6-2: Apply changes
Apply all deletions and modifications from 6-1 in one pass.
Show a diff of all changes before writing. Ask: "Apply?"
6-3: Verify
Run typecheck to confirm no new errors:
cd /workspace/growi && pnpm --filter @growi/app run lint:typecheck 2>&1 | grep "<model-file-name>"
Fix any errors that surface.
Show a completion summary:
✅ Migration + Cleanup Complete
Files changed:
- <source file path> (Prisma extension + Mongoose cleanup)
- apps/app/prisma/schema.prisma (<changed / no changes>)
- <caller files updated>
Remaining TODOs:
- Run pnpm prisma generate to regenerate types (if schema.prisma was changed)
- Add .$extends(extension) to apps/app/src/utils/prisma.ts to activate the new extension
- <source file> Mongoose schema block to be removed after all models have been migrated
- [run the sparse-index migration created in Step 2 (if applicable)]
Constraints
- Do NOT modify frontend code
- Do NOT run
prisma db pushorpnpm prisma generate - Do NOT remove the Mongoose schema block (the
new Schema(...)definition and field declarations) - Do NOT handle multiple model files in one invocation — one file per
/mongoose-to-prismacall - Relation default: always use
onDelete: NoAction, onUpdate: NoAction