@mandujs/core 0.54.31 → 0.55.0-beta.1
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.
- package/README.md +654 -654
- package/package.json +106 -210
- package/scripts/postinstall-lock.ts +153 -153
- package/src/a11y/__tests__/run-audit.test.ts +333 -333
- package/src/a11y/fix-hints.ts +76 -76
- package/src/a11y/index.ts +18 -18
- package/src/a11y/run-audit.ts +15 -15
- package/src/a11y/types.ts +125 -125
- package/src/agent/__tests__/apply.test.ts +224 -0
- package/src/agent/__tests__/context.test.ts +97 -98
- package/src/agent/apply.ts +1031 -0
- package/src/agent/context.ts +552 -552
- package/src/agent/index.ts +4 -3
- package/src/agent/plan.ts +161 -243
- package/src/agent/repair.ts +239 -162
- package/src/agent/sync.ts +197 -198
- package/src/agent/types.ts +242 -94
- package/src/agent/verify.ts +55 -55
- package/src/auth/__tests__/login.test.ts +1 -1
- package/src/auth/__tests__/password.test.ts +15 -5
- package/src/auth/__tests__/reset.test.ts +2 -2
- package/src/auth/__tests__/tokens.test.ts +274 -274
- package/src/auth/__tests__/verification.test.ts +274 -274
- package/src/auth/index.ts +76 -76
- package/src/auth/login.ts +225 -225
- package/src/auth/password.ts +14 -2
- package/src/auth/reset.ts +243 -243
- package/src/auth/tokens.ts +612 -612
- package/src/auth/verification.ts +253 -253
- package/src/brain/__tests__/redactor.test.ts +94 -94
- package/src/brain/adapters/__tests__/_helpers.ts +83 -83
- package/src/brain/adapters/__tests__/anthropic-oauth.test.ts +196 -196
- package/src/brain/adapters/__tests__/chatgpt-auth.test.ts +193 -193
- package/src/brain/adapters/__tests__/openai-oauth.test.ts +209 -209
- package/src/brain/adapters/__tests__/resolver.test.ts +143 -143
- package/src/brain/adapters/anthropic-oauth.ts +1 -1
- package/src/brain/adapters/chatgpt-auth.ts +300 -300
- package/src/brain/adapters/index.ts +319 -319
- package/src/brain/adapters/oauth-flow.ts +439 -439
- package/src/brain/consent.ts +240 -240
- package/src/brain/credentials.ts +396 -396
- package/src/brain/doctor/analyzer.ts +7 -7
- package/src/bundler/__snapshots__/build.test.ts.snap +5 -5
- package/src/bundler/__tests__/build-runner.ts +166 -166
- package/src/bundler/__tests__/client-boundary-transform.test.ts +524 -524
- package/src/bundler/__tests__/cold-start.test.ts +60 -60
- package/src/bundler/__tests__/css.test.ts +49 -20
- package/src/bundler/__tests__/dev-reliability.test.ts +619 -619
- package/src/bundler/__tests__/extended-watch.test.ts +711 -711
- package/src/bundler/__tests__/fast-refresh.test.ts +24 -24
- package/src/bundler/__tests__/generation.test.ts +447 -0
- package/src/bundler/__tests__/hdr.test.ts +24 -18
- package/src/bundler/__tests__/hmr-client.test.ts +62 -24
- package/src/bundler/__tests__/jsx-runtime-shim.test.ts +179 -140
- package/src/bundler/__tests__/manifest-schema.test.ts +305 -266
- package/src/bundler/__tests__/prod-smoke.test.ts +138 -138
- package/src/bundler/__tests__/reverse-import-graph.test.ts +42 -42
- package/src/bundler/__tests__/slot-dispatch.test.ts +573 -573
- package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -286
- package/src/bundler/__tests__/vendor-cache.test.ts +455 -455
- package/src/bundler/analyzer.ts +15 -15
- package/src/bundler/budget.ts +404 -404
- package/src/bundler/build.test.ts +898 -915
- package/src/bundler/build.ts +113 -24
- package/src/bundler/client-boundary-transform.ts +977 -977
- package/src/bundler/css.ts +65 -44
- package/src/bundler/dev.ts +105 -73
- package/src/bundler/fast-refresh-preamble.ts +47 -47
- package/src/bundler/generation.ts +602 -0
- package/src/bundler/index.ts +3 -3
- package/src/bundler/manifest-schema.ts +55 -40
- package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +13 -13
- package/src/bundler/plugins/__tests__/react-compiler-config.test.ts +83 -83
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -110
- package/src/bundler/plugins/block-generated-imports.ts +16 -15
- package/src/bundler/plugins/index.ts +83 -83
- package/src/bundler/plugins/react-compiler-config.ts +108 -108
- package/src/bundler/plugins/react-compiler-lint.ts +253 -253
- package/src/bundler/plugins/react-compiler.ts +162 -162
- package/src/bundler/prerender.ts +29 -9
- package/src/bundler/reverse-import-graph.ts +339 -339
- package/src/bundler/safe-build.test.ts +1 -1
- package/src/bundler/safe-build.ts +103 -103
- package/src/bundler/scenario-matrix.ts +229 -229
- package/src/bundler/types.ts +58 -56
- package/src/bundler/vendor-cache-types.ts +130 -130
- package/src/bundler/vendor-cache.ts +526 -526
- package/src/change/snapshot.ts +18 -18
- package/src/client/Form.tsx +105 -105
- package/src/client/Link.tsx +9 -9
- package/src/client/__tests__/props-serialization.test.ts +37 -37
- package/src/client/__tests__/use-sse.test.ts +153 -153
- package/src/client/globals.ts +1 -1
- package/src/client/hooks.ts +362 -362
- package/src/client/hydrate.ts +2 -2
- package/src/client/index.ts +1 -1
- package/src/client/island.ts +79 -79
- package/src/client/prefetch-helper.ts +55 -55
- package/src/client/props-serialization.ts +233 -233
- package/src/client/runtime-entry.ts +598 -598
- package/src/client/runtime.ts +1 -1
- package/src/client/serialize.ts +50 -50
- package/src/client/use-fetch.ts +6 -6
- package/src/client/use-head.ts +197 -197
- package/src/client/use-sse.ts +378 -378
- package/src/client/window-state.ts +101 -101
- package/src/components/Image-compat.ts +3 -0
- package/src/components/Image.tsx +162 -162
- package/src/config/validate.ts +1 -1
- package/src/config/watcher.ts +311 -311
- package/src/constants.ts +40 -40
- package/src/content/collection.ts +9 -9
- package/src/content/content-layer.ts +7 -7
- package/src/content/data-store.ts +245 -245
- package/src/content/frontmatter.ts +189 -189
- package/src/content/generate-types.ts +1 -1
- package/src/content/index.ts +2 -2
- package/src/content/loader-context.ts +171 -171
- package/src/content/loaders/api.ts +216 -216
- package/src/content/loaders/file.ts +172 -172
- package/src/content/loaders/glob.ts +253 -253
- package/src/content/loaders/index.ts +34 -34
- package/src/content/loaders/types.ts +137 -137
- package/src/content/meta-store.ts +209 -209
- package/src/content/prebuild.test.ts +571 -571
- package/src/content/prebuild.ts +636 -636
- package/src/content/schema.ts +20 -20
- package/src/content/sidebar.ts +630 -630
- package/src/content/slug.ts +110 -110
- package/src/content/types.ts +282 -282
- package/src/content/watcher.ts +135 -135
- package/src/contract/client-safe.test.ts +42 -42
- package/src/contract/client-safe.ts +114 -114
- package/src/contract/define.ts +11 -11
- package/src/contract/index.ts +1 -1
- package/src/contract/normalize.test.ts +276 -276
- package/src/contract/normalize.ts +410 -410
- package/src/contract/registry.test.ts +206 -206
- package/src/contract/route-helpers.ts +1 -1
- package/src/contract/rpc.ts +443 -443
- package/src/contract/types.ts +58 -58
- package/src/db/__tests__/db.test.ts +9 -9
- package/src/db/index.ts +2 -2
- package/src/db/migrations/history-table.ts +345 -345
- package/src/db/migrations/index.ts +3 -3
- package/src/db/migrations/lock.ts +324 -324
- package/src/db/migrations/runner.ts +650 -650
- package/src/deploy/cache.ts +140 -140
- package/src/deploy/compile/vercel.ts +344 -344
- package/src/deploy/index.ts +87 -87
- package/src/deploy/inference/brain.ts +268 -268
- package/src/deploy/inference/context.ts +82 -82
- package/src/deploy/inference/filling-extract.ts +245 -245
- package/src/deploy/inference/heuristic.ts +182 -182
- package/src/deploy/intent.ts +173 -173
- package/src/deploy/plan.ts +178 -178
- package/src/design/__tests__/agents-link.test.ts +109 -109
- package/src/design/__tests__/extract-patch-diff.test.ts +265 -265
- package/src/design/__tests__/lint.test.ts +110 -110
- package/src/design/__tests__/parser.test.ts +195 -195
- package/src/design/__tests__/tailwind-theme.test.ts +229 -229
- package/src/design/agents-link.ts +165 -165
- package/src/design/diff.ts +138 -138
- package/src/design/extract.ts +285 -285
- package/src/design/index.ts +102 -102
- package/src/design/lint.ts +209 -209
- package/src/design/parser.ts +555 -555
- package/src/design/patch.ts +241 -241
- package/src/design/scaffold.ts +147 -147
- package/src/design/tailwind-theme.ts +441 -441
- package/src/design/types.ts +210 -210
- package/src/desktop/__tests__/smoke.test.ts +2 -2
- package/src/desktop/__tests__/webview-fallback.test.ts +12 -32
- package/src/desktop/__tests__/window.test.ts +20 -99
- package/src/desktop/__tests__/worker.test.ts +266 -266
- package/src/desktop/index.ts +43 -43
- package/src/desktop/types.ts +158 -158
- package/src/desktop/webview-fallback.ts +17 -4
- package/src/desktop/window.ts +17 -8
- package/src/desktop/worker.ts +180 -180
- package/src/dev-error-overlay/__tests__/overlay-injector.test.ts +241 -241
- package/src/dev-error-overlay/index.ts +30 -30
- package/src/dev-error-overlay/overlay-injector.ts +243 -243
- package/src/dev-error-overlay/overlay-styles.ts +52 -52
- package/src/dev-error-overlay/types.ts +66 -66
- package/src/devtools/ai/context-builder.ts +375 -375
- package/src/devtools/ai/index.ts +25 -25
- package/src/devtools/ai/mcp-connector.ts +25 -25
- package/src/devtools/client/catchers/error-catcher.ts +344 -344
- package/src/devtools/client/catchers/index.ts +18 -18
- package/src/devtools/client/components/index.ts +39 -39
- package/src/devtools/client/components/mandu-character.tsx +331 -331
- package/src/devtools/client/components/panel/errors-panel.tsx +259 -259
- package/src/devtools/client/components/panel/guard-panel.tsx +30 -30
- package/src/devtools/client/components/panel/islands-panel.tsx +16 -16
- package/src/devtools/client/components/panel/network-panel.tsx +291 -291
- package/src/devtools/client/components/panel/panel-container.tsx +1 -1
- package/src/devtools/client/components/panel/preview-panel.tsx +46 -46
- package/src/devtools/client/filters/context-filters.ts +282 -282
- package/src/devtools/client/filters/index.ts +16 -16
- package/src/devtools/client/index.ts +63 -63
- package/src/devtools/hook/create-hook.ts +207 -207
- package/src/devtools/hook/index.ts +13 -13
- package/src/devtools/index.ts +439 -439
- package/src/devtools/init.ts +265 -265
- package/src/devtools/protocol.ts +237 -237
- package/src/devtools/server/index.ts +17 -17
- package/src/devtools/types.ts +35 -35
- package/src/devtools/worker/index.ts +25 -25
- package/src/devtools/worker/redaction-worker.ts +233 -233
- package/src/diagnose/__tests__/checks.test.ts +136 -136
- package/src/diagnose/checks.ts +185 -185
- package/src/diagnose/index.ts +17 -17
- package/src/diagnose/run.ts +10 -10
- package/src/diagnose/types.ts +53 -53
- package/src/email/__tests__/email.test.ts +355 -355
- package/src/email/index.ts +282 -282
- package/src/email/smtp.ts +64 -64
- package/src/error/domains.ts +265 -265
- package/src/error/types.ts +6 -6
- package/src/errors/extractor.ts +409 -409
- package/src/errors/index.ts +19 -19
- package/src/filling/__tests__/session-sqlite.test.ts +5 -1
- package/src/filling/auth.ts +308 -308
- package/src/filling/body-parse.test.ts +60 -60
- package/src/filling/cookie-codec.ts +5 -3
- package/src/filling/deps.ts +265 -265
- package/src/filling/head-method.test.ts +154 -154
- package/src/filling/session-sqlite.ts +617 -617
- package/src/filling/sse.ts +5 -5
- package/src/filling/ws.ts +78 -78
- package/src/generator/generate.ts +30 -30
- package/src/generator/index.ts +3 -3
- package/src/generator/templates.test.ts +48 -48
- package/src/generator/templates.ts +219 -219
- package/src/guard/__tests__/design-inline-class.test.ts +219 -219
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -347
- package/src/guard/analyzer.ts +360 -360
- package/src/guard/auto-correct.ts +1 -1
- package/src/guard/check.ts +39 -24
- package/src/guard/config-guard.ts +13 -13
- package/src/guard/contract-guard.ts +9 -9
- package/src/guard/define-rule.ts +243 -243
- package/src/guard/design-inline-class.ts +393 -393
- package/src/guard/file-type.test.ts +24 -24
- package/src/guard/fs-routes-policy.ts +51 -51
- package/src/guard/graph.ts +1 -1
- package/src/guard/healing.ts +36 -36
- package/src/guard/index.ts +11 -11
- package/src/guard/presets/atomic.ts +70 -70
- package/src/guard/presets/clean.ts +77 -77
- package/src/guard/presets/fsd.ts +79 -79
- package/src/guard/presets/hexagonal.ts +68 -68
- package/src/guard/reporter.ts +442 -442
- package/src/guard/rule-presets.ts +379 -379
- package/src/guard/semantic-slots.ts +1 -1
- package/src/guard/suggestions.ts +358 -358
- package/src/guard/tsgolint-bridge.ts +512 -512
- package/src/guard/types.ts +348 -348
- package/src/guard/watcher.ts +405 -405
- package/src/i18n/define.ts +126 -126
- package/src/i18n/index.ts +52 -52
- package/src/i18n/message-registry.ts +173 -173
- package/src/i18n/types.ts +112 -112
- package/src/id/__tests__/id.test.ts +3 -3
- package/src/id/index.ts +105 -105
- package/src/index.ts +9 -48
- package/src/internal/client-boundary.ts +266 -266
- package/src/internal/index.ts +2 -2
- package/src/kitchen/api/agent-devtools-api.ts +779 -779
- package/src/kitchen/api/bundle-inspector.ts +179 -0
- package/src/kitchen/api/errors-grouping.ts +126 -126
- package/src/kitchen/api/file-api.ts +11 -11
- package/src/kitchen/kitchen-handler.ts +36 -0
- package/src/kitchen/kitchen-ui.ts +456 -0
- package/src/logging/index.ts +22 -22
- package/src/logging/transports.ts +365 -365
- package/src/middleware/bridge.ts +147 -147
- package/src/middleware/compose.ts +134 -134
- package/src/middleware/compress.ts +62 -62
- package/src/middleware/cors.ts +47 -47
- package/src/middleware/csrf.ts +328 -328
- package/src/middleware/define.ts +132 -132
- package/src/middleware/jwt.ts +134 -134
- package/src/middleware/logger.ts +58 -58
- package/src/middleware/oauth/__tests__/oauth.test.ts +1 -1
- package/src/middleware/oauth/index.ts +505 -505
- package/src/middleware/oauth/providers.ts +115 -115
- package/src/middleware/rate-limit/index.ts +522 -522
- package/src/middleware/rate-limit/sqlite-store.ts +382 -382
- package/src/middleware/scheduler-cron.ts +96 -96
- package/src/middleware/secure/__tests__/secure.test.ts +360 -360
- package/src/middleware/secure/csp.ts +193 -193
- package/src/middleware/session.ts +174 -174
- package/src/middleware/timeout.ts +55 -55
- package/src/observability/logger-adapter.ts +36 -36
- package/src/observability/sqlite-store.ts +254 -254
- package/src/openapi/generator.ts +1 -1
- package/src/openapi/openapi.test.ts +43 -43
- package/src/perf/__tests__/user-marks.test.ts +354 -354
- package/src/perf/index.ts +133 -133
- package/src/perf/user-marks.ts +1 -1
- package/src/plugins/__tests__/lifecycle-integration.test.ts +272 -272
- package/src/plugins/__tests__/runner.test.ts +409 -409
- package/src/plugins/define.ts +124 -124
- package/src/plugins/examples/dep-check-plugin.ts +80 -80
- package/src/plugins/examples/prerender-cache-plugin.ts +111 -111
- package/src/plugins/examples/sitemap-plugin.ts +65 -65
- package/src/plugins/runner.ts +361 -361
- package/src/plugins/types.ts +368 -368
- package/src/report/index.ts +1 -1
- package/src/resource/__tests__/generator.test.ts +7 -7
- package/src/resource/__tests__/schema.test.ts +14 -14
- package/src/resource/ddl/__tests__/diff.test.ts +639 -639
- package/src/resource/ddl/__tests__/emit.test.ts +165 -165
- package/src/resource/ddl/__tests__/snapshot.test.ts +499 -499
- package/src/resource/ddl/emit.ts +146 -146
- package/src/resource/ddl/persistence-types.ts +218 -218
- package/src/resource/ddl/type-map.ts +223 -223
- package/src/resource/ddl/types.ts +232 -232
- package/src/resource/generator-repo.ts +630 -630
- package/src/resource/generator-schema.ts +11 -11
- package/src/resource/generators/slot.ts +72 -72
- package/src/resource/schema.ts +21 -21
- package/src/router/client-entry.test.ts +227 -227
- package/src/router/client-entry.ts +218 -218
- package/src/router/fs-patterns.test.ts +96 -96
- package/src/router/fs-routes.test.ts +532 -532
- package/src/router/fs-routes.ts +53 -53
- package/src/router/fs-scanner.ts +216 -216
- package/src/router/fs-types.ts +19 -19
- package/src/router/route-source-analyzer.ts +521 -521
- package/src/routes/index.ts +74 -74
- package/src/routes/metadata-routes.ts +427 -427
- package/src/routes/types.ts +341 -341
- package/src/runtime/__tests__/devtools-adapter.test.ts +68 -68
- package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -141
- package/src/runtime/__tests__/hdr-client.test.ts +223 -223
- package/src/runtime/__tests__/http-errors.test.ts +117 -117
- package/src/runtime/__tests__/inline-client-hydration.test.ts +237 -237
- package/src/runtime/__tests__/not-found.test.ts +152 -152
- package/src/runtime/__tests__/observability-lifecycle.test.ts +103 -103
- package/src/runtime/__tests__/page-render-response.test.ts +517 -517
- package/src/runtime/__tests__/request-middleware.test.ts +70 -70
- package/src/runtime/__tests__/searchparams-page-props.test.ts +81 -81
- package/src/runtime/adapter.ts +47 -47
- package/src/runtime/boundary.tsx +252 -252
- package/src/runtime/cache.ts +494 -494
- package/src/runtime/compose.ts +222 -222
- package/src/runtime/devtools-adapter.ts +68 -68
- package/src/runtime/escape.ts +34 -34
- package/src/runtime/fast-refresh-runtime.ts +322 -322
- package/src/runtime/fast-refresh-types.ts +1 -1
- package/src/runtime/handler.ts +65 -65
- package/src/runtime/handlers.ts +30 -11
- package/src/runtime/http-errors.ts +113 -113
- package/src/runtime/image-handler.ts +1 -1
- package/src/runtime/lifecycle.ts +381 -381
- package/src/runtime/logger.test.ts +345 -345
- package/src/runtime/middleware.ts +264 -264
- package/src/runtime/not-found.ts +93 -93
- package/src/runtime/observability-lifecycle.ts +290 -290
- package/src/runtime/openapi-endpoint.ts +236 -236
- package/src/runtime/page-render-response.ts +287 -274
- package/src/runtime/ppr.ts +74 -74
- package/src/runtime/rate-limit.ts +1 -1
- package/src/runtime/redirect.ts +1 -1
- package/src/runtime/registry.ts +171 -171
- package/src/runtime/request-middleware.ts +31 -31
- package/src/runtime/router.test.ts +4 -4
- package/src/runtime/router.ts +10 -10
- package/src/runtime/server.ts +39 -20
- package/src/runtime/shims.ts +48 -48
- package/src/runtime/ssr.ts +43 -17
- package/src/runtime/static-files.ts +369 -289
- package/src/runtime/streaming-ssr.ts +219 -180
- package/src/runtime/trace.ts +144 -144
- package/src/scheduler/__tests__/scheduler.test.ts +12 -11
- package/src/scheduler/index.ts +11 -5
- package/src/scheduler/validate.ts +169 -169
- package/src/seo/index.ts +219 -219
- package/src/seo/integration/ssr.ts +306 -306
- package/src/seo/render/basic.ts +435 -435
- package/src/seo/render/index.ts +143 -143
- package/src/seo/render/jsonld.ts +539 -539
- package/src/seo/render/opengraph.ts +197 -197
- package/src/seo/render/robots.ts +116 -116
- package/src/seo/render/sitemap.ts +137 -137
- package/src/seo/render/twitter.ts +127 -127
- package/src/seo/resolve/opengraph.ts +143 -143
- package/src/seo/resolve/robots.ts +73 -73
- package/src/seo/resolve/title.ts +94 -94
- package/src/seo/resolve/twitter.ts +73 -73
- package/src/seo/resolve/url.ts +104 -104
- package/src/seo/routes/index.ts +290 -290
- package/src/seo/types.ts +588 -588
- package/src/slot/validator.ts +39 -39
- package/src/spec/schema.ts +35 -35
- package/src/storage/s3/__tests__/s3.test.ts +479 -479
- package/src/storage/s3/index.ts +412 -412
- package/src/testing/__tests__/assertions.test.ts +632 -632
- package/src/testing/__tests__/reporter.test.ts +454 -454
- package/src/testing/assertions.ts +986 -986
- package/src/testing/db.ts +157 -157
- package/src/testing/index.ts +9 -3
- package/src/testing/lcov.ts +192 -0
- package/src/testing/mocks.ts +203 -203
- package/src/testing/session.ts +190 -190
- package/src/types/branded.ts +56 -56
- package/src/types/index.ts +1 -1
- package/src/utils/safe-io.ts +188 -188
- package/src/utils/string-safe.ts +298 -298
- package/src/watcher/__tests__/watcher.test.ts +59 -59
- package/src/watcher/watcher.ts +61 -61
|
@@ -1,630 +1,630 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Phase 4c — Resource repo generator.
|
|
3
|
-
*
|
|
4
|
-
* Given a `ParsedResource` with `options.persistence` set, emit the full
|
|
5
|
-
* TypeScript source of `.mandu/generated/server/repos/{name}.repo.ts` — a
|
|
6
|
-
* typed CRUD bundle (`findById`, `findMany`, `create`, `update`, `delete`)
|
|
7
|
-
* sitting on top of `@mandujs/core/db`.
|
|
8
|
-
*
|
|
9
|
-
* # Invariants
|
|
10
|
-
*
|
|
11
|
-
* 1. PURE FUNCTION. No I/O, no filesystem, no `Bun.write`. The orchestrator
|
|
12
|
-
* in `generator.ts` handles writes.
|
|
13
|
-
* 2. The Row type is emitted INLINE as a TypeScript interface matching
|
|
14
|
-
* the resource's fields. We do NOT import the Zod schema from the
|
|
15
|
-
* contract file — the existing contract generator emits `XSchema`
|
|
16
|
-
* as a module-private const, not a named export, and Appendix B TC-3
|
|
17
|
-
* prohibits changing the contract's public surface in this phase.
|
|
18
|
-
* 3. Column names in SQL are `snake_case` — sourced from
|
|
19
|
-
* `snapshotFromResources` normalization. We import `toSnakeCase` from
|
|
20
|
-
* `ddl/snapshot.ts` (DRY; single canonical transform).
|
|
21
|
-
* 4. Dialect branching is RESOLVED AT GENERATION TIME from
|
|
22
|
-
* `persistence.provider`. The emitted file never runtime-switches on
|
|
23
|
-
* provider — there's one code path per file.
|
|
24
|
-
* 5. All identifiers emitted into SQL strings flow through `quoteIdent`
|
|
25
|
-
* (imported from `ddl/emit.ts`). Table/column names come from the
|
|
26
|
-
* (author-validated) resource definition; `quoteIdent` adds defense
|
|
27
|
-
* in depth.
|
|
28
|
-
* 6. `// @generated by Mandu — do not edit.` banner at the top so
|
|
29
|
-
* humans + CI tooling recognize the file as derived.
|
|
30
|
-
*
|
|
31
|
-
* # Emit shape
|
|
32
|
-
*
|
|
33
|
-
* export interface User { id: string; email: string; ... }
|
|
34
|
-
* export function createUsersRepo(db: Db) {
|
|
35
|
-
* return {
|
|
36
|
-
* async findById(id): User | null { ... },
|
|
37
|
-
* async findMany(limit = 100, offset = 0): User[] { ... },
|
|
38
|
-
* async create(row): User { ... }, // PG/SQLite: RETURNING *; MySQL: INSERT + re-select
|
|
39
|
-
* async update(id, patch): User | null { ... },
|
|
40
|
-
* async delete(id): boolean { ... },
|
|
41
|
-
* };
|
|
42
|
-
* }
|
|
43
|
-
*
|
|
44
|
-
* The repo name `createUsersRepo` uses the table's plural form (what
|
|
45
|
-
* `snapshotFromResources` resolves to) rather than the singular
|
|
46
|
-
* `resource.name`. This matches what developers naturally type when
|
|
47
|
-
* calling it: `createUsersRepo`, `createPostsRepo`, etc.
|
|
48
|
-
*
|
|
49
|
-
* # Non-goals
|
|
50
|
-
*
|
|
51
|
-
* - Implicit transaction context (Appendix D.3 — v2).
|
|
52
|
-
* - JOIN helpers / multi-table queries — use raw `db` directly in slots.
|
|
53
|
-
* - Partial column selection — always `SELECT *` for type stability.
|
|
54
|
-
* - Custom error types — thrown errors bubble; users handle them.
|
|
55
|
-
*
|
|
56
|
-
* # References
|
|
57
|
-
*
|
|
58
|
-
* - docs/rfcs/0001-db-resource-layer.md §D1 (5th artifact)
|
|
59
|
-
* - docs/rfcs/0001-db-resource-layer.md §D2 (Zod-inferred row type)
|
|
60
|
-
* - docs/rfcs/0001-db-resource-layer.md Appendix D.1 (dialect divergence)
|
|
61
|
-
* - DNA/drizzle-orm/drizzle-kit/src/sqlgenerator.ts (reference templates)
|
|
62
|
-
*/
|
|
63
|
-
|
|
64
|
-
import type { ParsedResource } from "./parser";
|
|
65
|
-
import type { ResourceField } from "./schema";
|
|
66
|
-
import { quoteIdent } from "./ddl/emit";
|
|
67
|
-
import { toSnakeCase } from "./ddl/snapshot";
|
|
68
|
-
import { asPersistence, type ExtendedResourcePersistence, type FieldOverride } from "./ddl/persistence-types";
|
|
69
|
-
import type { SqlProvider } from "./ddl/types";
|
|
70
|
-
|
|
71
|
-
// ============================================
|
|
72
|
-
// Public API
|
|
73
|
-
// ============================================
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Options controlling `generateRepoSource` behavior.
|
|
77
|
-
*
|
|
78
|
-
* All fields are optional. Defaults are chosen so the generator "just works"
|
|
79
|
-
* when called from `generateResourceArtifacts` with no explicit options.
|
|
80
|
-
*/
|
|
81
|
-
export interface RepoGenerationOptions {
|
|
82
|
-
/**
|
|
83
|
-
* When `false`, return `null` for non-persistent resources instead of
|
|
84
|
-
* throwing. Defaults to `true` (throw on non-persistent) — the
|
|
85
|
-
* orchestrator in `generator.ts` guards against this path by checking
|
|
86
|
-
* `options.persistence` before calling us. Advanced callers can pass
|
|
87
|
-
* `enable: false` to treat missing persistence as a no-op.
|
|
88
|
-
*/
|
|
89
|
-
enable?: boolean;
|
|
90
|
-
/**
|
|
91
|
-
* Import specifier for `Db` type. Default `@mandujs/core/db`. Useful in
|
|
92
|
-
* monorepo tests that need to point at a local build.
|
|
93
|
-
*/
|
|
94
|
-
dbImport?: string;
|
|
95
|
-
/**
|
|
96
|
-
* Override the contract import path. When not set, the generator uses
|
|
97
|
-
* `../contracts/{resource.name}.contract` which matches the output of
|
|
98
|
-
* `resolveGeneratedPaths()`. Pass this only when you're generating the
|
|
99
|
-
* repo into a non-standard location.
|
|
100
|
-
*/
|
|
101
|
-
contractImport?: string;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Return the full TypeScript source of a resource's repo module.
|
|
106
|
-
*
|
|
107
|
-
* Pure — no filesystem, no side effects. Caller writes the result.
|
|
108
|
-
*
|
|
109
|
-
* @throws TypeError when the resource has no `options.persistence` and
|
|
110
|
-
* `options.enable` is not explicitly `false`.
|
|
111
|
-
*/
|
|
112
|
-
export function generateRepoSource(
|
|
113
|
-
resource: ParsedResource,
|
|
114
|
-
options: RepoGenerationOptions = {},
|
|
115
|
-
): string | null {
|
|
116
|
-
const enable = options.enable ?? true;
|
|
117
|
-
const rawPersistence = (
|
|
118
|
-
resource.definition.options as Record<string, unknown> | undefined
|
|
119
|
-
)?.persistence;
|
|
120
|
-
const persistence = asPersistence(rawPersistence);
|
|
121
|
-
|
|
122
|
-
if (!persistence) {
|
|
123
|
-
if (enable === false) return null;
|
|
124
|
-
throw new TypeError(
|
|
125
|
-
`generateRepoSource: resource "${resource.resourceName}" has no options.persistence. ` +
|
|
126
|
-
`Either add a persistence block or pass { enable: false } to skip repo generation.`,
|
|
127
|
-
);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const dbImport = options.dbImport ?? "@mandujs/core/db";
|
|
131
|
-
const contractImport =
|
|
132
|
-
options.contractImport ?? `../contracts/${resource.definition.name}.contract`;
|
|
133
|
-
|
|
134
|
-
return renderRepoFile(resource, persistence, dbImport, contractImport);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Back-compat predicate exported for `generator.ts` to decide whether to
|
|
139
|
-
* emit a repo without duplicating the `asPersistence` narrowing logic.
|
|
140
|
-
*/
|
|
141
|
-
export function shouldEmitRepo(resource: ParsedResource): boolean {
|
|
142
|
-
const rawPersistence = (
|
|
143
|
-
resource.definition.options as Record<string, unknown> | undefined
|
|
144
|
-
)?.persistence;
|
|
145
|
-
return asPersistence(rawPersistence) !== undefined;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// ============================================
|
|
149
|
-
// Renderer — top-level composition
|
|
150
|
-
// ============================================
|
|
151
|
-
|
|
152
|
-
function renderRepoFile(
|
|
153
|
-
resource: ParsedResource,
|
|
154
|
-
persistence: ExtendedResourcePersistence,
|
|
155
|
-
dbImport: string,
|
|
156
|
-
contractImport: string,
|
|
157
|
-
): string {
|
|
158
|
-
const { definition } = resource;
|
|
159
|
-
const pascalName = toPascalCase(definition.name);
|
|
160
|
-
// Plural name from persistence.tableName > auto-plural. Matches what
|
|
161
|
-
// `snapshotFromResources` resolves to — keeps the repo name + table
|
|
162
|
-
// name aligned so stack traces are readable.
|
|
163
|
-
const table = resolveTableName(resource, persistence);
|
|
164
|
-
const repoFactoryName = `create${toPascalCase(table)}Repo`;
|
|
165
|
-
const provider = persistence.provider;
|
|
166
|
-
|
|
167
|
-
// Canonical column list — keys are the author's field names
|
|
168
|
-
// (e.g., `passwordHash`), columns are the normalized SQL names
|
|
169
|
-
// (`password_hash`). Column overrides from `fieldOverrides` take
|
|
170
|
-
// precedence, matching `snapshot.ts` behavior.
|
|
171
|
-
const columns = resolveColumns(resource, persistence);
|
|
172
|
-
const primaryKeyColumn = columns.find((c) => c.primary)?.column ?? "id";
|
|
173
|
-
const primaryKeyField = columns.find((c) => c.primary)?.field ?? "id";
|
|
174
|
-
const insertColumns = columns.filter((c) => !c.omitOnInsert);
|
|
175
|
-
const createInputType = createMethodInputType(pascalName, columns);
|
|
176
|
-
|
|
177
|
-
// Quoted identifiers — precomputed so each builder reads cleanly.
|
|
178
|
-
const q = (name: string) => escapeSqlTemplateText(quoteIdent(name, provider));
|
|
179
|
-
const qTable = q(table);
|
|
180
|
-
|
|
181
|
-
// Column list for SELECT: we don't use `*` because aliasing
|
|
182
|
-
// snake_case → camelCase is needed to return the Row type the contract
|
|
183
|
-
// expects. The `AS` alias is emitted only when column != field (avoid
|
|
184
|
-
// `name AS name` noise).
|
|
185
|
-
const selectList = columns
|
|
186
|
-
.map((c) =>
|
|
187
|
-
c.column === c.field ? q(c.column) : `${q(c.column)} AS ${q(c.field)}`,
|
|
188
|
-
)
|
|
189
|
-
.join(", ");
|
|
190
|
-
|
|
191
|
-
const header = renderHeader(definition.name, repoFactoryName, pascalName);
|
|
192
|
-
const imports = renderImports(dbImport, contractImport, pascalName);
|
|
193
|
-
const rowTypeBlock = renderRowType(pascalName, Object.entries(definition.fields));
|
|
194
|
-
|
|
195
|
-
const body = [
|
|
196
|
-
findByIdMethod(pascalName, qTable, selectList, q, primaryKeyColumn, primaryKeyField),
|
|
197
|
-
findManyMethod(pascalName, qTable, selectList),
|
|
198
|
-
createMethod(pascalName, table, qTable, selectList, insertColumns, createInputType, primaryKeyColumn, primaryKeyField, q, provider),
|
|
199
|
-
updateMethod(pascalName, table, qTable, selectList, columns, primaryKeyColumn, primaryKeyField, q, provider),
|
|
200
|
-
deleteMethod(pascalName, qTable, q, primaryKeyColumn, primaryKeyField, provider),
|
|
201
|
-
].join(",\n\n");
|
|
202
|
-
|
|
203
|
-
return `${header}${imports}
|
|
204
|
-
${rowTypeBlock}
|
|
205
|
-
|
|
206
|
-
export function ${repoFactoryName}(db: Db) {
|
|
207
|
-
return {
|
|
208
|
-
${body},
|
|
209
|
-
};
|
|
210
|
-
}
|
|
211
|
-
`;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// ============================================
|
|
215
|
-
// Headers / imports / row type
|
|
216
|
-
// ============================================
|
|
217
|
-
|
|
218
|
-
function renderHeader(
|
|
219
|
-
resourceName: string,
|
|
220
|
-
repoFactoryName: string,
|
|
221
|
-
pascalName: string,
|
|
222
|
-
): string {
|
|
223
|
-
return `// @generated by Mandu — do not edit.
|
|
224
|
-
// Regenerate with \`mandu generate\` or \`mandu db plan\`.
|
|
225
|
-
//
|
|
226
|
-
// Resource: ${resourceName}
|
|
227
|
-
// Factory: ${repoFactoryName}(db) → typed CRUD bundle
|
|
228
|
-
// Row type: ${pascalName} (inline interface mirroring the contract schema)
|
|
229
|
-
//
|
|
230
|
-
// IMPLEMENTATION NOTES
|
|
231
|
-
// - Column names in SQL are snake_case; the Row type keeps camelCase by
|
|
232
|
-
// aliasing in the SELECT list (\`AS "fieldName"\`).
|
|
233
|
-
// - The repo never constructs a Db itself — callers (slots, scripts) pass
|
|
234
|
-
// in either \`ctx.deps.db\` or a module-level singleton.
|
|
235
|
-
// - Provider-specific SQL (INSERT ... RETURNING * on PG/SQLite vs
|
|
236
|
-
// INSERT + re-select on MySQL) is resolved at generation
|
|
237
|
-
// time; the generated file has one code path per provider.
|
|
238
|
-
`;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
function renderImports(dbImport: string, _contractImport: string, _pascalName: string): string {
|
|
242
|
-
// The repo's Row type is emitted INLINE (as a TypeScript interface)
|
|
243
|
-
// rather than inferred from the contract's Zod schema.
|
|
244
|
-
//
|
|
245
|
-
// Why not `z.infer<typeof XSchema>`? The existing contract generator
|
|
246
|
-
// (`generators/contract.ts:77`) emits `XSchema` as a module-private
|
|
247
|
-
// `const`, not a named export. Touching the contract emitter to export
|
|
248
|
-
// it is a cross-artifact API change that Appendix B TC-3 is explicitly
|
|
249
|
-
// protecting against. Inlining the Row type keeps the repo artifact
|
|
250
|
-
// self-contained and preserves the contract's existing public surface.
|
|
251
|
-
//
|
|
252
|
-
// The inline interface mirrors Mandu's `ResourceField` → TS mapping
|
|
253
|
-
// exactly; correctness is guarded by the repo's unit tests.
|
|
254
|
-
return `
|
|
255
|
-
import type { Db } from "${dbImport}";
|
|
256
|
-
`;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
function renderRowType(pascalName: string, fields: Array<[string, ResourceField]>): string {
|
|
260
|
-
const lines = fields.map(([name, field]) => {
|
|
261
|
-
const tsType = fieldToTsType(field);
|
|
262
|
-
// Fields are REQUIRED only when explicitly set. Matches the contract
|
|
263
|
-
// generator's behavior (`generators/contract.ts:144`: appends
|
|
264
|
-
// `.optional()` to the Zod schema when `required` is falsy).
|
|
265
|
-
const optional = field.required === true ? "" : "?";
|
|
266
|
-
return ` ${name}${optional}: ${tsType};`;
|
|
267
|
-
});
|
|
268
|
-
// Index signature satisfies `Db`'s `Row = Record<string, unknown>`
|
|
269
|
-
// constraint so the generated repo type-checks when the callers pass
|
|
270
|
-
// `${pascalName}` as the row type param to `db<${pascalName}>` / `db.one<${pascalName}>`.
|
|
271
|
-
// Concrete fields above retain their precise types — the signature
|
|
272
|
-
// only widens unknown keys.
|
|
273
|
-
return `/**
|
|
274
|
-
* Row shape for the \`${pascalName}\` resource. Mirrors the contract's
|
|
275
|
-
* Zod schema — if you add or remove fields in the resource definition,
|
|
276
|
-
* regenerate to pick up the change.
|
|
277
|
-
*/
|
|
278
|
-
export interface ${pascalName} {
|
|
279
|
-
${lines.join("\n")}
|
|
280
|
-
[key: string]: unknown;
|
|
281
|
-
}`;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/**
|
|
285
|
-
* Map a Mandu `ResourceField` to its TypeScript row type.
|
|
286
|
-
*
|
|
287
|
-
* Keep in sync with `generators/contract.ts:generateZodSchema` — the two
|
|
288
|
-
* must agree or the Row type will diverge from the contract. Primitive
|
|
289
|
-
* types only; complex shapes fall back to `unknown` which is explicit
|
|
290
|
-
* and easy to spot at call sites.
|
|
291
|
-
*/
|
|
292
|
-
function fieldToTsType(field: ResourceField): string {
|
|
293
|
-
switch (field.type) {
|
|
294
|
-
case "string":
|
|
295
|
-
case "uuid":
|
|
296
|
-
case "email":
|
|
297
|
-
case "url":
|
|
298
|
-
case "date":
|
|
299
|
-
return "string";
|
|
300
|
-
case "number":
|
|
301
|
-
return "number";
|
|
302
|
-
case "boolean":
|
|
303
|
-
return "boolean";
|
|
304
|
-
case "array":
|
|
305
|
-
return "unknown[]";
|
|
306
|
-
case "json":
|
|
307
|
-
case "object":
|
|
308
|
-
return "Record<string, unknown>";
|
|
309
|
-
default:
|
|
310
|
-
return "unknown";
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
// ============================================
|
|
315
|
-
// Method emitters — each returns the method text
|
|
316
|
-
// ============================================
|
|
317
|
-
|
|
318
|
-
function findByIdMethod(
|
|
319
|
-
pascalName: string,
|
|
320
|
-
qTable: string,
|
|
321
|
-
selectList: string,
|
|
322
|
-
q: (n: string) => string,
|
|
323
|
-
pkColumn: string,
|
|
324
|
-
pkField: string,
|
|
325
|
-
): string {
|
|
326
|
-
return ` /**
|
|
327
|
-
* Look up a single row by primary key.
|
|
328
|
-
* Returns \`null\` when no row matches — never throws for "not found".
|
|
329
|
-
*/
|
|
330
|
-
async findById(${pkField}: ${pascalName}["${pkField}"]): Promise<${pascalName} | null> {
|
|
331
|
-
return db.one<${pascalName}>\`SELECT ${selectList} FROM ${qTable} WHERE ${q(pkColumn)} = \${${pkField}}\`;
|
|
332
|
-
}`;
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
function findManyMethod(pascalName: string, qTable: string, selectList: string): string {
|
|
336
|
-
return ` /**
|
|
337
|
-
* List rows with basic pagination. Default: 100 rows, no offset.
|
|
338
|
-
* Ordering is \`<primary-key> ASC\` — callers that need custom ordering
|
|
339
|
-
* should drop to raw \`db\` in the slot.
|
|
340
|
-
*/
|
|
341
|
-
async findMany(limit: number = 100, offset: number = 0): Promise<${pascalName}[]> {
|
|
342
|
-
return db<${pascalName}>\`SELECT ${selectList} FROM ${qTable} LIMIT \${limit} OFFSET \${offset}\`;
|
|
343
|
-
}`;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
function createMethod(
|
|
347
|
-
pascalName: string,
|
|
348
|
-
tableName: string,
|
|
349
|
-
qTable: string,
|
|
350
|
-
selectList: string,
|
|
351
|
-
insertColumns: ResolvedColumn[],
|
|
352
|
-
createInputType: string,
|
|
353
|
-
pkColumn: string,
|
|
354
|
-
pkField: string,
|
|
355
|
-
q: (n: string) => string,
|
|
356
|
-
provider: SqlProvider,
|
|
357
|
-
): string {
|
|
358
|
-
// Build INSERT column list + VALUES placeholder list. Each value is
|
|
359
|
-
// sourced from the `input` object and bound via Bun.SQL's tagged-template
|
|
360
|
-
// parameter mechanism — never concatenated into the SQL string.
|
|
361
|
-
const columnList = insertColumns.map((c) => q(c.column)).join(", ");
|
|
362
|
-
const valuePlaceholders = insertColumns
|
|
363
|
-
.map((c) => `\${input.${c.field}}`)
|
|
364
|
-
.join(", ");
|
|
365
|
-
|
|
366
|
-
if (provider === "postgres" || provider === "sqlite") {
|
|
367
|
-
// RETURNING * is native on both dialects (SQLite >= 3.35 — Bun's bundled
|
|
368
|
-
// SQLite is modern enough per Agent A's emit.ts note). We reuse the
|
|
369
|
-
// precomputed `selectList` on RETURNING so newly-generated DB-side
|
|
370
|
-
// defaults come back with the inserted row and snake_case → camelCase
|
|
371
|
-
// aliasing is consistent with SELECT.
|
|
372
|
-
return ` /**
|
|
373
|
-
* Insert a new row and return the inserted record. Errors from
|
|
374
|
-
* constraint violations (UNIQUE, NOT NULL, FK) bubble unchanged.
|
|
375
|
-
*/
|
|
376
|
-
async create(input: ${createInputType}): Promise<${pascalName}> {
|
|
377
|
-
const row = await db.one<${pascalName}>\`
|
|
378
|
-
INSERT INTO ${qTable} (${columnList})
|
|
379
|
-
VALUES (${valuePlaceholders})
|
|
380
|
-
RETURNING ${selectList}
|
|
381
|
-
\`;
|
|
382
|
-
if (!row) throw new Error("${tableName} create: INSERT RETURNING produced no row");
|
|
383
|
-
return row;
|
|
384
|
-
}`;
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
const mysqlLookup = insertColumns.some((c) => c.field === pkField)
|
|
388
|
-
? `${q(pkColumn)} = \${input.${pkField}}`
|
|
389
|
-
: `${q(pkColumn)} = LAST_INSERT_ID()`;
|
|
390
|
-
|
|
391
|
-
// MySQL: no RETURNING. INSERT then SELECT by the supplied primary key when
|
|
392
|
-
// the caller provides it; if the primary key is DB-generated, fall back to
|
|
393
|
-
// LAST_INSERT_ID().
|
|
394
|
-
return ` /**
|
|
395
|
-
* Insert a new row and return the inserted record via a follow-up SELECT.
|
|
396
|
-
* MySQL lacks RETURNING; the generator uses LAST_INSERT_ID() when the
|
|
397
|
-
* primary key is server-generated, otherwise it re-selects by the
|
|
398
|
-
* provided primary key value.
|
|
399
|
-
*/
|
|
400
|
-
async create(input: ${createInputType}): Promise<${pascalName}> {
|
|
401
|
-
await db\`
|
|
402
|
-
INSERT INTO ${qTable} (${columnList})
|
|
403
|
-
VALUES (${valuePlaceholders})
|
|
404
|
-
\`;
|
|
405
|
-
const row = await db.one<${pascalName}>\`
|
|
406
|
-
SELECT ${selectList}
|
|
407
|
-
FROM ${qTable}
|
|
408
|
-
WHERE ${mysqlLookup}
|
|
409
|
-
\`;
|
|
410
|
-
if (!row) throw new Error("${tableName} create: follow-up SELECT returned no row");
|
|
411
|
-
return row;
|
|
412
|
-
}`;
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
function updateMethod(
|
|
416
|
-
pascalName: string,
|
|
417
|
-
_tableName: string,
|
|
418
|
-
qTable: string,
|
|
419
|
-
selectList: string,
|
|
420
|
-
allColumns: ResolvedColumn[],
|
|
421
|
-
pkColumn: string,
|
|
422
|
-
pkField: string,
|
|
423
|
-
q: (n: string) => string,
|
|
424
|
-
_provider: SqlProvider,
|
|
425
|
-
): string {
|
|
426
|
-
// Strategy: per-column UPDATE statements inside a transaction so the SQL
|
|
427
|
-
// remains a well-formed tagged template at all times (no dynamic SET
|
|
428
|
-
// string-building). Trade-off: up to N round-trips for N patched fields,
|
|
429
|
-
// but portable across all three dialects with no string concatenation.
|
|
430
|
-
// Repo users who need a hot write path drop to raw `db` in the slot.
|
|
431
|
-
//
|
|
432
|
-
// We emit a per-column UPDATE whose table + column identifiers are
|
|
433
|
-
// already baked into the generated source as string literals inside
|
|
434
|
-
// the tagged template. Fields in `fieldToColumn` drive the runtime
|
|
435
|
-
// iteration; the actual UPDATE statement for each column is a static
|
|
436
|
-
// fragment selected by a switch on the field name. This keeps every
|
|
437
|
-
// SQL string a true tagged template with no interpolated identifiers.
|
|
438
|
-
const updateCases = allColumns
|
|
439
|
-
.filter((c) => !c.primary)
|
|
440
|
-
.map(
|
|
441
|
-
(c) => ` case "${c.field}":
|
|
442
|
-
await tx\`UPDATE ${qTable} SET ${q(c.column)} = \${value} WHERE ${q(pkColumn)} = \${${pkField}}\`;
|
|
443
|
-
break;`,
|
|
444
|
-
)
|
|
445
|
-
.join("\n");
|
|
446
|
-
|
|
447
|
-
return ` /**
|
|
448
|
-
* Update an existing row by primary key. Fields left \`undefined\`
|
|
449
|
-
* in \`patch\` are not modified. Returns the updated row, or
|
|
450
|
-
* \`null\` when no row with the given primary key exists.
|
|
451
|
-
*
|
|
452
|
-
* Implementation detail: the dynamic SET is emitted as a sequence of
|
|
453
|
-
* single-column UPDATEs within a transaction. This keeps the SQL a
|
|
454
|
-
* well-formed tagged template across all three dialects at the cost
|
|
455
|
-
* of N round-trips for N patched fields. For hot write paths, drop
|
|
456
|
-
* to raw \`db\` in your slot.
|
|
457
|
-
*/
|
|
458
|
-
async update(
|
|
459
|
-
${pkField}: ${pascalName}["${pkField}"],
|
|
460
|
-
patch: Partial<Omit<${pascalName}, "${pkField}">>,
|
|
461
|
-
): Promise<${pascalName} | null> {
|
|
462
|
-
return db.transaction(async (tx) => {
|
|
463
|
-
const existing = await tx.one<${pascalName}>\`
|
|
464
|
-
SELECT ${selectList} FROM ${qTable} WHERE ${q(pkColumn)} = \${${pkField}}
|
|
465
|
-
\`;
|
|
466
|
-
if (!existing) return null;
|
|
467
|
-
for (const [field, value] of Object.entries(patch as Record<string, unknown>)) {
|
|
468
|
-
if (value === undefined) continue;
|
|
469
|
-
switch (field) {
|
|
470
|
-
${updateCases}
|
|
471
|
-
default:
|
|
472
|
-
// Unknown key — silently ignore. Contract validation at the
|
|
473
|
-
// slot layer should prevent this from reaching the repo.
|
|
474
|
-
break;
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
return tx.one<${pascalName}>\`
|
|
478
|
-
SELECT ${selectList} FROM ${qTable} WHERE ${q(pkColumn)} = \${${pkField}}
|
|
479
|
-
\`;
|
|
480
|
-
});
|
|
481
|
-
}`;
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
function deleteMethod(
|
|
485
|
-
pascalName: string,
|
|
486
|
-
qTable: string,
|
|
487
|
-
q: (n: string) => string,
|
|
488
|
-
pkColumn: string,
|
|
489
|
-
pkField: string,
|
|
490
|
-
_provider: SqlProvider,
|
|
491
|
-
): string {
|
|
492
|
-
// All three providers support DELETE; we return true iff at least one
|
|
493
|
-
// row was removed. Rather than rely on driver-specific affected-row
|
|
494
|
-
// reporting (`count` on PG, `changes()` on SQLite, `affectedRows` on
|
|
495
|
-
// MySQL) we do a cheap SELECT-then-DELETE so behavior is identical
|
|
496
|
-
// across dialects. Same correctness-over-perf trade-off as `update`:
|
|
497
|
-
// drop to raw `db` in a slot for hot delete paths.
|
|
498
|
-
return ` /**
|
|
499
|
-
* Delete a row by primary key. Returns \`true\` if a row was deleted,
|
|
500
|
-
* \`false\` if no row matched.
|
|
501
|
-
*/
|
|
502
|
-
async delete(${pkField}: ${pascalName}["${pkField}"]): Promise<boolean> {
|
|
503
|
-
const existing = await db.one\`SELECT 1 AS ok FROM ${qTable} WHERE ${q(pkColumn)} = \${${pkField}} LIMIT 1\`;
|
|
504
|
-
if (!existing) return false;
|
|
505
|
-
await db\`DELETE FROM ${qTable} WHERE ${q(pkColumn)} = \${${pkField}}\`;
|
|
506
|
-
return true;
|
|
507
|
-
}`;
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
// ============================================
|
|
511
|
-
// Column + table resolution
|
|
512
|
-
// ============================================
|
|
513
|
-
|
|
514
|
-
interface ResolvedColumn {
|
|
515
|
-
/** Field key the author wrote in `definition.fields` (e.g. `passwordHash`). */
|
|
516
|
-
field: string;
|
|
517
|
-
/** Column name in SQL (e.g. `password_hash`). */
|
|
518
|
-
column: string;
|
|
519
|
-
primary: boolean;
|
|
520
|
-
/** PK auto-generated by the DB → skip from INSERT column list. */
|
|
521
|
-
omitOnInsert: boolean;
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
function resolveColumns(
|
|
525
|
-
resource: ParsedResource,
|
|
526
|
-
persistence: ExtendedResourcePersistence,
|
|
527
|
-
): ResolvedColumn[] {
|
|
528
|
-
const { definition } = resource;
|
|
529
|
-
const overrides = persistence.fieldOverrides ?? {};
|
|
530
|
-
const declaredPk = resolveDeclaredPrimaryKey(persistence.primaryKey);
|
|
531
|
-
|
|
532
|
-
const columns: ResolvedColumn[] = [];
|
|
533
|
-
let pkCount = 0;
|
|
534
|
-
|
|
535
|
-
for (const [fieldKey, field] of Object.entries(definition.fields)) {
|
|
536
|
-
const override = overrides[fieldKey];
|
|
537
|
-
const column = override?.columnName ?? toSnakeCase(fieldKey);
|
|
538
|
-
// `primary` resolution mirrors `snapshot.ts`:
|
|
539
|
-
// - named by persistence.primaryKey, OR
|
|
540
|
-
// - field-level `primary: true` (via cast — Mandu's public
|
|
541
|
-
// ResourceField type predates this flag).
|
|
542
|
-
const declaredPkMatch = declaredPk !== undefined && declaredPk === fieldKey;
|
|
543
|
-
const fieldLevelPk = Boolean(
|
|
544
|
-
(field as { primary?: boolean }).primary,
|
|
545
|
-
);
|
|
546
|
-
const primary = declaredPkMatch || fieldLevelPk;
|
|
547
|
-
if (primary) pkCount++;
|
|
548
|
-
|
|
549
|
-
const omitOnInsert = primary && hasDbDefault(field, override);
|
|
550
|
-
|
|
551
|
-
columns.push({ field: fieldKey, column, primary, omitOnInsert });
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
if (pkCount === 0) {
|
|
555
|
-
throw new TypeError(
|
|
556
|
-
`Resource "${resource.resourceName}" has no primary-key field. Mark one with \`primary: true\` (via fieldOverrides) or declare \`options.persistence.primaryKey\`.`,
|
|
557
|
-
);
|
|
558
|
-
}
|
|
559
|
-
if (pkCount > 1) {
|
|
560
|
-
throw new TypeError(
|
|
561
|
-
`Resource "${resource.resourceName}" has ${pkCount} primary-key fields. Composite primary keys are not supported in v1.`,
|
|
562
|
-
);
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
return columns;
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
function resolveDeclaredPrimaryKey(
|
|
569
|
-
declared: ExtendedResourcePersistence["primaryKey"],
|
|
570
|
-
): string | undefined {
|
|
571
|
-
if (declared === undefined) return undefined;
|
|
572
|
-
if (typeof declared === "string") return declared;
|
|
573
|
-
return declared[0];
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
function createMethodInputType(
|
|
577
|
-
pascalName: string,
|
|
578
|
-
columns: ResolvedColumn[],
|
|
579
|
-
): string {
|
|
580
|
-
const omittedFields = columns
|
|
581
|
-
.filter((c) => c.omitOnInsert)
|
|
582
|
-
.map((c) => JSON.stringify(c.field));
|
|
583
|
-
if (omittedFields.length === 0) return pascalName;
|
|
584
|
-
return `Omit<${pascalName}, ${omittedFields.join(" | ")}>`;
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
function hasDbDefault(
|
|
588
|
-
field: ResourceField,
|
|
589
|
-
override: FieldOverride | undefined,
|
|
590
|
-
): boolean {
|
|
591
|
-
return override?.default !== undefined || field.default !== undefined;
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
function escapeSqlTemplateText(text: string): string {
|
|
595
|
-
return text.replace(/`/g, "\\`");
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
function resolveTableName(
|
|
599
|
-
resource: ParsedResource,
|
|
600
|
-
persistence: ExtendedResourcePersistence,
|
|
601
|
-
): string {
|
|
602
|
-
if (persistence.tableName) return persistence.tableName;
|
|
603
|
-
const options = resource.definition.options;
|
|
604
|
-
if (options?.pluralName) return options.pluralName;
|
|
605
|
-
if (options?.autoPlural === false) return resource.definition.name;
|
|
606
|
-
return pluralize(resource.definition.name);
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
/**
|
|
610
|
-
* Shared pluralizer — MUST match `snapshot.ts`. If the two diverge the
|
|
611
|
-
* repo's table name won't match the actual table → runtime SQL errors.
|
|
612
|
-
* Kept as a private copy (vs import) because `snapshot.ts` doesn't export
|
|
613
|
-
* its pluralizer; duplication is guarded by a test that compares both.
|
|
614
|
-
*/
|
|
615
|
-
function pluralize(singular: string): string {
|
|
616
|
-
if (/[^aeiou]y$/i.test(singular)) {
|
|
617
|
-
return singular.slice(0, -1) + "ies";
|
|
618
|
-
}
|
|
619
|
-
if (/(?:s|x|z|ch|sh)$/i.test(singular)) {
|
|
620
|
-
return singular + "es";
|
|
621
|
-
}
|
|
622
|
-
return singular + "s";
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
function toPascalCase(str: string): string {
|
|
626
|
-
return str
|
|
627
|
-
.split(/[-_\s]/)
|
|
628
|
-
.map((part) => (part.length === 0 ? "" : part[0]!.toUpperCase() + part.slice(1)))
|
|
629
|
-
.join("");
|
|
630
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Phase 4c — Resource repo generator.
|
|
3
|
+
*
|
|
4
|
+
* Given a `ParsedResource` with `options.persistence` set, emit the full
|
|
5
|
+
* TypeScript source of `.mandu/generated/server/repos/{name}.repo.ts` — a
|
|
6
|
+
* typed CRUD bundle (`findById`, `findMany`, `create`, `update`, `delete`)
|
|
7
|
+
* sitting on top of `@mandujs/core/db`.
|
|
8
|
+
*
|
|
9
|
+
* # Invariants
|
|
10
|
+
*
|
|
11
|
+
* 1. PURE FUNCTION. No I/O, no filesystem, no `Bun.write`. The orchestrator
|
|
12
|
+
* in `generator.ts` handles writes.
|
|
13
|
+
* 2. The Row type is emitted INLINE as a TypeScript interface matching
|
|
14
|
+
* the resource's fields. We do NOT import the Zod schema from the
|
|
15
|
+
* contract file — the existing contract generator emits `XSchema`
|
|
16
|
+
* as a module-private const, not a named export, and Appendix B TC-3
|
|
17
|
+
* prohibits changing the contract's public surface in this phase.
|
|
18
|
+
* 3. Column names in SQL are `snake_case` — sourced from
|
|
19
|
+
* `snapshotFromResources` normalization. We import `toSnakeCase` from
|
|
20
|
+
* `ddl/snapshot.ts` (DRY; single canonical transform).
|
|
21
|
+
* 4. Dialect branching is RESOLVED AT GENERATION TIME from
|
|
22
|
+
* `persistence.provider`. The emitted file never runtime-switches on
|
|
23
|
+
* provider — there's one code path per file.
|
|
24
|
+
* 5. All identifiers emitted into SQL strings flow through `quoteIdent`
|
|
25
|
+
* (imported from `ddl/emit.ts`). Table/column names come from the
|
|
26
|
+
* (author-validated) resource definition; `quoteIdent` adds defense
|
|
27
|
+
* in depth.
|
|
28
|
+
* 6. `// @generated by Mandu — do not edit.` banner at the top so
|
|
29
|
+
* humans + CI tooling recognize the file as derived.
|
|
30
|
+
*
|
|
31
|
+
* # Emit shape
|
|
32
|
+
*
|
|
33
|
+
* export interface User { id: string; email: string; ... }
|
|
34
|
+
* export function createUsersRepo(db: Db) {
|
|
35
|
+
* return {
|
|
36
|
+
* async findById(id): User | null { ... },
|
|
37
|
+
* async findMany(limit = 100, offset = 0): User[] { ... },
|
|
38
|
+
* async create(row): User { ... }, // PG/SQLite: RETURNING *; MySQL: INSERT + re-select
|
|
39
|
+
* async update(id, patch): User | null { ... },
|
|
40
|
+
* async delete(id): boolean { ... },
|
|
41
|
+
* };
|
|
42
|
+
* }
|
|
43
|
+
*
|
|
44
|
+
* The repo name `createUsersRepo` uses the table's plural form (what
|
|
45
|
+
* `snapshotFromResources` resolves to) rather than the singular
|
|
46
|
+
* `resource.name`. This matches what developers naturally type when
|
|
47
|
+
* calling it: `createUsersRepo`, `createPostsRepo`, etc.
|
|
48
|
+
*
|
|
49
|
+
* # Non-goals
|
|
50
|
+
*
|
|
51
|
+
* - Implicit transaction context (Appendix D.3 — v2).
|
|
52
|
+
* - JOIN helpers / multi-table queries — use raw `db` directly in slots.
|
|
53
|
+
* - Partial column selection — always `SELECT *` for type stability.
|
|
54
|
+
* - Custom error types — thrown errors bubble; users handle them.
|
|
55
|
+
*
|
|
56
|
+
* # References
|
|
57
|
+
*
|
|
58
|
+
* - docs/rfcs/0001-db-resource-layer.md §D1 (5th artifact)
|
|
59
|
+
* - docs/rfcs/0001-db-resource-layer.md §D2 (Zod-inferred row type)
|
|
60
|
+
* - docs/rfcs/0001-db-resource-layer.md Appendix D.1 (dialect divergence)
|
|
61
|
+
* - DNA/drizzle-orm/drizzle-kit/src/sqlgenerator.ts (reference templates)
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
import type { ParsedResource } from "./parser";
|
|
65
|
+
import type { ResourceField } from "./schema";
|
|
66
|
+
import { quoteIdent } from "./ddl/emit";
|
|
67
|
+
import { toSnakeCase } from "./ddl/snapshot";
|
|
68
|
+
import { asPersistence, type ExtendedResourcePersistence, type FieldOverride } from "./ddl/persistence-types";
|
|
69
|
+
import type { SqlProvider } from "./ddl/types";
|
|
70
|
+
|
|
71
|
+
// ============================================
|
|
72
|
+
// Public API
|
|
73
|
+
// ============================================
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Options controlling `generateRepoSource` behavior.
|
|
77
|
+
*
|
|
78
|
+
* All fields are optional. Defaults are chosen so the generator "just works"
|
|
79
|
+
* when called from `generateResourceArtifacts` with no explicit options.
|
|
80
|
+
*/
|
|
81
|
+
export interface RepoGenerationOptions {
|
|
82
|
+
/**
|
|
83
|
+
* When `false`, return `null` for non-persistent resources instead of
|
|
84
|
+
* throwing. Defaults to `true` (throw on non-persistent) — the
|
|
85
|
+
* orchestrator in `generator.ts` guards against this path by checking
|
|
86
|
+
* `options.persistence` before calling us. Advanced callers can pass
|
|
87
|
+
* `enable: false` to treat missing persistence as a no-op.
|
|
88
|
+
*/
|
|
89
|
+
enable?: boolean;
|
|
90
|
+
/**
|
|
91
|
+
* Import specifier for `Db` type. Default `@mandujs/core/db`. Useful in
|
|
92
|
+
* monorepo tests that need to point at a local build.
|
|
93
|
+
*/
|
|
94
|
+
dbImport?: string;
|
|
95
|
+
/**
|
|
96
|
+
* Override the contract import path. When not set, the generator uses
|
|
97
|
+
* `../contracts/{resource.name}.contract` which matches the output of
|
|
98
|
+
* `resolveGeneratedPaths()`. Pass this only when you're generating the
|
|
99
|
+
* repo into a non-standard location.
|
|
100
|
+
*/
|
|
101
|
+
contractImport?: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Return the full TypeScript source of a resource's repo module.
|
|
106
|
+
*
|
|
107
|
+
* Pure — no filesystem, no side effects. Caller writes the result.
|
|
108
|
+
*
|
|
109
|
+
* @throws TypeError when the resource has no `options.persistence` and
|
|
110
|
+
* `options.enable` is not explicitly `false`.
|
|
111
|
+
*/
|
|
112
|
+
export function generateRepoSource(
|
|
113
|
+
resource: ParsedResource,
|
|
114
|
+
options: RepoGenerationOptions = {},
|
|
115
|
+
): string | null {
|
|
116
|
+
const enable = options.enable ?? true;
|
|
117
|
+
const rawPersistence = (
|
|
118
|
+
resource.definition.options as Record<string, unknown> | undefined
|
|
119
|
+
)?.persistence;
|
|
120
|
+
const persistence = asPersistence(rawPersistence);
|
|
121
|
+
|
|
122
|
+
if (!persistence) {
|
|
123
|
+
if (enable === false) return null;
|
|
124
|
+
throw new TypeError(
|
|
125
|
+
`generateRepoSource: resource "${resource.resourceName}" has no options.persistence. ` +
|
|
126
|
+
`Either add a persistence block or pass { enable: false } to skip repo generation.`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const dbImport = options.dbImport ?? "@mandujs/core/compat/db/index";
|
|
131
|
+
const contractImport =
|
|
132
|
+
options.contractImport ?? `../contracts/${resource.definition.name}.contract`;
|
|
133
|
+
|
|
134
|
+
return renderRepoFile(resource, persistence, dbImport, contractImport);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Back-compat predicate exported for `generator.ts` to decide whether to
|
|
139
|
+
* emit a repo without duplicating the `asPersistence` narrowing logic.
|
|
140
|
+
*/
|
|
141
|
+
export function shouldEmitRepo(resource: ParsedResource): boolean {
|
|
142
|
+
const rawPersistence = (
|
|
143
|
+
resource.definition.options as Record<string, unknown> | undefined
|
|
144
|
+
)?.persistence;
|
|
145
|
+
return asPersistence(rawPersistence) !== undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ============================================
|
|
149
|
+
// Renderer — top-level composition
|
|
150
|
+
// ============================================
|
|
151
|
+
|
|
152
|
+
function renderRepoFile(
|
|
153
|
+
resource: ParsedResource,
|
|
154
|
+
persistence: ExtendedResourcePersistence,
|
|
155
|
+
dbImport: string,
|
|
156
|
+
contractImport: string,
|
|
157
|
+
): string {
|
|
158
|
+
const { definition } = resource;
|
|
159
|
+
const pascalName = toPascalCase(definition.name);
|
|
160
|
+
// Plural name from persistence.tableName > auto-plural. Matches what
|
|
161
|
+
// `snapshotFromResources` resolves to — keeps the repo name + table
|
|
162
|
+
// name aligned so stack traces are readable.
|
|
163
|
+
const table = resolveTableName(resource, persistence);
|
|
164
|
+
const repoFactoryName = `create${toPascalCase(table)}Repo`;
|
|
165
|
+
const provider = persistence.provider;
|
|
166
|
+
|
|
167
|
+
// Canonical column list — keys are the author's field names
|
|
168
|
+
// (e.g., `passwordHash`), columns are the normalized SQL names
|
|
169
|
+
// (`password_hash`). Column overrides from `fieldOverrides` take
|
|
170
|
+
// precedence, matching `snapshot.ts` behavior.
|
|
171
|
+
const columns = resolveColumns(resource, persistence);
|
|
172
|
+
const primaryKeyColumn = columns.find((c) => c.primary)?.column ?? "id";
|
|
173
|
+
const primaryKeyField = columns.find((c) => c.primary)?.field ?? "id";
|
|
174
|
+
const insertColumns = columns.filter((c) => !c.omitOnInsert);
|
|
175
|
+
const createInputType = createMethodInputType(pascalName, columns);
|
|
176
|
+
|
|
177
|
+
// Quoted identifiers — precomputed so each builder reads cleanly.
|
|
178
|
+
const q = (name: string) => escapeSqlTemplateText(quoteIdent(name, provider));
|
|
179
|
+
const qTable = q(table);
|
|
180
|
+
|
|
181
|
+
// Column list for SELECT: we don't use `*` because aliasing
|
|
182
|
+
// snake_case → camelCase is needed to return the Row type the contract
|
|
183
|
+
// expects. The `AS` alias is emitted only when column != field (avoid
|
|
184
|
+
// `name AS name` noise).
|
|
185
|
+
const selectList = columns
|
|
186
|
+
.map((c) =>
|
|
187
|
+
c.column === c.field ? q(c.column) : `${q(c.column)} AS ${q(c.field)}`,
|
|
188
|
+
)
|
|
189
|
+
.join(", ");
|
|
190
|
+
|
|
191
|
+
const header = renderHeader(definition.name, repoFactoryName, pascalName);
|
|
192
|
+
const imports = renderImports(dbImport, contractImport, pascalName);
|
|
193
|
+
const rowTypeBlock = renderRowType(pascalName, Object.entries(definition.fields));
|
|
194
|
+
|
|
195
|
+
const body = [
|
|
196
|
+
findByIdMethod(pascalName, qTable, selectList, q, primaryKeyColumn, primaryKeyField),
|
|
197
|
+
findManyMethod(pascalName, qTable, selectList),
|
|
198
|
+
createMethod(pascalName, table, qTable, selectList, insertColumns, createInputType, primaryKeyColumn, primaryKeyField, q, provider),
|
|
199
|
+
updateMethod(pascalName, table, qTable, selectList, columns, primaryKeyColumn, primaryKeyField, q, provider),
|
|
200
|
+
deleteMethod(pascalName, qTable, q, primaryKeyColumn, primaryKeyField, provider),
|
|
201
|
+
].join(",\n\n");
|
|
202
|
+
|
|
203
|
+
return `${header}${imports}
|
|
204
|
+
${rowTypeBlock}
|
|
205
|
+
|
|
206
|
+
export function ${repoFactoryName}(db: Db) {
|
|
207
|
+
return {
|
|
208
|
+
${body},
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ============================================
|
|
215
|
+
// Headers / imports / row type
|
|
216
|
+
// ============================================
|
|
217
|
+
|
|
218
|
+
function renderHeader(
|
|
219
|
+
resourceName: string,
|
|
220
|
+
repoFactoryName: string,
|
|
221
|
+
pascalName: string,
|
|
222
|
+
): string {
|
|
223
|
+
return `// @generated by Mandu — do not edit.
|
|
224
|
+
// Regenerate with \`mandu generate\` or \`mandu db plan\`.
|
|
225
|
+
//
|
|
226
|
+
// Resource: ${resourceName}
|
|
227
|
+
// Factory: ${repoFactoryName}(db) → typed CRUD bundle
|
|
228
|
+
// Row type: ${pascalName} (inline interface mirroring the contract schema)
|
|
229
|
+
//
|
|
230
|
+
// IMPLEMENTATION NOTES
|
|
231
|
+
// - Column names in SQL are snake_case; the Row type keeps camelCase by
|
|
232
|
+
// aliasing in the SELECT list (\`AS "fieldName"\`).
|
|
233
|
+
// - The repo never constructs a Db itself — callers (slots, scripts) pass
|
|
234
|
+
// in either \`ctx.deps.db\` or a module-level singleton.
|
|
235
|
+
// - Provider-specific SQL (INSERT ... RETURNING * on PG/SQLite vs
|
|
236
|
+
// INSERT + re-select on MySQL) is resolved at generation
|
|
237
|
+
// time; the generated file has one code path per provider.
|
|
238
|
+
`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function renderImports(dbImport: string, _contractImport: string, _pascalName: string): string {
|
|
242
|
+
// The repo's Row type is emitted INLINE (as a TypeScript interface)
|
|
243
|
+
// rather than inferred from the contract's Zod schema.
|
|
244
|
+
//
|
|
245
|
+
// Why not `z.infer<typeof XSchema>`? The existing contract generator
|
|
246
|
+
// (`generators/contract.ts:77`) emits `XSchema` as a module-private
|
|
247
|
+
// `const`, not a named export. Touching the contract emitter to export
|
|
248
|
+
// it is a cross-artifact API change that Appendix B TC-3 is explicitly
|
|
249
|
+
// protecting against. Inlining the Row type keeps the repo artifact
|
|
250
|
+
// self-contained and preserves the contract's existing public surface.
|
|
251
|
+
//
|
|
252
|
+
// The inline interface mirrors Mandu's `ResourceField` → TS mapping
|
|
253
|
+
// exactly; correctness is guarded by the repo's unit tests.
|
|
254
|
+
return `
|
|
255
|
+
import type { Db } from "${dbImport}";
|
|
256
|
+
`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function renderRowType(pascalName: string, fields: Array<[string, ResourceField]>): string {
|
|
260
|
+
const lines = fields.map(([name, field]) => {
|
|
261
|
+
const tsType = fieldToTsType(field);
|
|
262
|
+
// Fields are REQUIRED only when explicitly set. Matches the contract
|
|
263
|
+
// generator's behavior (`generators/contract.ts:144`: appends
|
|
264
|
+
// `.optional()` to the Zod schema when `required` is falsy).
|
|
265
|
+
const optional = field.required === true ? "" : "?";
|
|
266
|
+
return ` ${name}${optional}: ${tsType};`;
|
|
267
|
+
});
|
|
268
|
+
// Index signature satisfies `Db`'s `Row = Record<string, unknown>`
|
|
269
|
+
// constraint so the generated repo type-checks when the callers pass
|
|
270
|
+
// `${pascalName}` as the row type param to `db<${pascalName}>` / `db.one<${pascalName}>`.
|
|
271
|
+
// Concrete fields above retain their precise types — the signature
|
|
272
|
+
// only widens unknown keys.
|
|
273
|
+
return `/**
|
|
274
|
+
* Row shape for the \`${pascalName}\` resource. Mirrors the contract's
|
|
275
|
+
* Zod schema — if you add or remove fields in the resource definition,
|
|
276
|
+
* regenerate to pick up the change.
|
|
277
|
+
*/
|
|
278
|
+
export interface ${pascalName} {
|
|
279
|
+
${lines.join("\n")}
|
|
280
|
+
[key: string]: unknown;
|
|
281
|
+
}`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Map a Mandu `ResourceField` to its TypeScript row type.
|
|
286
|
+
*
|
|
287
|
+
* Keep in sync with `generators/contract.ts:generateZodSchema` — the two
|
|
288
|
+
* must agree or the Row type will diverge from the contract. Primitive
|
|
289
|
+
* types only; complex shapes fall back to `unknown` which is explicit
|
|
290
|
+
* and easy to spot at call sites.
|
|
291
|
+
*/
|
|
292
|
+
function fieldToTsType(field: ResourceField): string {
|
|
293
|
+
switch (field.type) {
|
|
294
|
+
case "string":
|
|
295
|
+
case "uuid":
|
|
296
|
+
case "email":
|
|
297
|
+
case "url":
|
|
298
|
+
case "date":
|
|
299
|
+
return "string";
|
|
300
|
+
case "number":
|
|
301
|
+
return "number";
|
|
302
|
+
case "boolean":
|
|
303
|
+
return "boolean";
|
|
304
|
+
case "array":
|
|
305
|
+
return "unknown[]";
|
|
306
|
+
case "json":
|
|
307
|
+
case "object":
|
|
308
|
+
return "Record<string, unknown>";
|
|
309
|
+
default:
|
|
310
|
+
return "unknown";
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ============================================
|
|
315
|
+
// Method emitters — each returns the method text
|
|
316
|
+
// ============================================
|
|
317
|
+
|
|
318
|
+
function findByIdMethod(
|
|
319
|
+
pascalName: string,
|
|
320
|
+
qTable: string,
|
|
321
|
+
selectList: string,
|
|
322
|
+
q: (n: string) => string,
|
|
323
|
+
pkColumn: string,
|
|
324
|
+
pkField: string,
|
|
325
|
+
): string {
|
|
326
|
+
return ` /**
|
|
327
|
+
* Look up a single row by primary key.
|
|
328
|
+
* Returns \`null\` when no row matches — never throws for "not found".
|
|
329
|
+
*/
|
|
330
|
+
async findById(${pkField}: ${pascalName}["${pkField}"]): Promise<${pascalName} | null> {
|
|
331
|
+
return db.one<${pascalName}>\`SELECT ${selectList} FROM ${qTable} WHERE ${q(pkColumn)} = \${${pkField}}\`;
|
|
332
|
+
}`;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function findManyMethod(pascalName: string, qTable: string, selectList: string): string {
|
|
336
|
+
return ` /**
|
|
337
|
+
* List rows with basic pagination. Default: 100 rows, no offset.
|
|
338
|
+
* Ordering is \`<primary-key> ASC\` — callers that need custom ordering
|
|
339
|
+
* should drop to raw \`db\` in the slot.
|
|
340
|
+
*/
|
|
341
|
+
async findMany(limit: number = 100, offset: number = 0): Promise<${pascalName}[]> {
|
|
342
|
+
return db<${pascalName}>\`SELECT ${selectList} FROM ${qTable} LIMIT \${limit} OFFSET \${offset}\`;
|
|
343
|
+
}`;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function createMethod(
|
|
347
|
+
pascalName: string,
|
|
348
|
+
tableName: string,
|
|
349
|
+
qTable: string,
|
|
350
|
+
selectList: string,
|
|
351
|
+
insertColumns: ResolvedColumn[],
|
|
352
|
+
createInputType: string,
|
|
353
|
+
pkColumn: string,
|
|
354
|
+
pkField: string,
|
|
355
|
+
q: (n: string) => string,
|
|
356
|
+
provider: SqlProvider,
|
|
357
|
+
): string {
|
|
358
|
+
// Build INSERT column list + VALUES placeholder list. Each value is
|
|
359
|
+
// sourced from the `input` object and bound via Bun.SQL's tagged-template
|
|
360
|
+
// parameter mechanism — never concatenated into the SQL string.
|
|
361
|
+
const columnList = insertColumns.map((c) => q(c.column)).join(", ");
|
|
362
|
+
const valuePlaceholders = insertColumns
|
|
363
|
+
.map((c) => `\${input.${c.field}}`)
|
|
364
|
+
.join(", ");
|
|
365
|
+
|
|
366
|
+
if (provider === "postgres" || provider === "sqlite") {
|
|
367
|
+
// RETURNING * is native on both dialects (SQLite >= 3.35 — Bun's bundled
|
|
368
|
+
// SQLite is modern enough per Agent A's emit.ts note). We reuse the
|
|
369
|
+
// precomputed `selectList` on RETURNING so newly-generated DB-side
|
|
370
|
+
// defaults come back with the inserted row and snake_case → camelCase
|
|
371
|
+
// aliasing is consistent with SELECT.
|
|
372
|
+
return ` /**
|
|
373
|
+
* Insert a new row and return the inserted record. Errors from
|
|
374
|
+
* constraint violations (UNIQUE, NOT NULL, FK) bubble unchanged.
|
|
375
|
+
*/
|
|
376
|
+
async create(input: ${createInputType}): Promise<${pascalName}> {
|
|
377
|
+
const row = await db.one<${pascalName}>\`
|
|
378
|
+
INSERT INTO ${qTable} (${columnList})
|
|
379
|
+
VALUES (${valuePlaceholders})
|
|
380
|
+
RETURNING ${selectList}
|
|
381
|
+
\`;
|
|
382
|
+
if (!row) throw new Error("${tableName} create: INSERT RETURNING produced no row");
|
|
383
|
+
return row;
|
|
384
|
+
}`;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const mysqlLookup = insertColumns.some((c) => c.field === pkField)
|
|
388
|
+
? `${q(pkColumn)} = \${input.${pkField}}`
|
|
389
|
+
: `${q(pkColumn)} = LAST_INSERT_ID()`;
|
|
390
|
+
|
|
391
|
+
// MySQL: no RETURNING. INSERT then SELECT by the supplied primary key when
|
|
392
|
+
// the caller provides it; if the primary key is DB-generated, fall back to
|
|
393
|
+
// LAST_INSERT_ID().
|
|
394
|
+
return ` /**
|
|
395
|
+
* Insert a new row and return the inserted record via a follow-up SELECT.
|
|
396
|
+
* MySQL lacks RETURNING; the generator uses LAST_INSERT_ID() when the
|
|
397
|
+
* primary key is server-generated, otherwise it re-selects by the
|
|
398
|
+
* provided primary key value.
|
|
399
|
+
*/
|
|
400
|
+
async create(input: ${createInputType}): Promise<${pascalName}> {
|
|
401
|
+
await db\`
|
|
402
|
+
INSERT INTO ${qTable} (${columnList})
|
|
403
|
+
VALUES (${valuePlaceholders})
|
|
404
|
+
\`;
|
|
405
|
+
const row = await db.one<${pascalName}>\`
|
|
406
|
+
SELECT ${selectList}
|
|
407
|
+
FROM ${qTable}
|
|
408
|
+
WHERE ${mysqlLookup}
|
|
409
|
+
\`;
|
|
410
|
+
if (!row) throw new Error("${tableName} create: follow-up SELECT returned no row");
|
|
411
|
+
return row;
|
|
412
|
+
}`;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function updateMethod(
|
|
416
|
+
pascalName: string,
|
|
417
|
+
_tableName: string,
|
|
418
|
+
qTable: string,
|
|
419
|
+
selectList: string,
|
|
420
|
+
allColumns: ResolvedColumn[],
|
|
421
|
+
pkColumn: string,
|
|
422
|
+
pkField: string,
|
|
423
|
+
q: (n: string) => string,
|
|
424
|
+
_provider: SqlProvider,
|
|
425
|
+
): string {
|
|
426
|
+
// Strategy: per-column UPDATE statements inside a transaction so the SQL
|
|
427
|
+
// remains a well-formed tagged template at all times (no dynamic SET
|
|
428
|
+
// string-building). Trade-off: up to N round-trips for N patched fields,
|
|
429
|
+
// but portable across all three dialects with no string concatenation.
|
|
430
|
+
// Repo users who need a hot write path drop to raw `db` in the slot.
|
|
431
|
+
//
|
|
432
|
+
// We emit a per-column UPDATE whose table + column identifiers are
|
|
433
|
+
// already baked into the generated source as string literals inside
|
|
434
|
+
// the tagged template. Fields in `fieldToColumn` drive the runtime
|
|
435
|
+
// iteration; the actual UPDATE statement for each column is a static
|
|
436
|
+
// fragment selected by a switch on the field name. This keeps every
|
|
437
|
+
// SQL string a true tagged template with no interpolated identifiers.
|
|
438
|
+
const updateCases = allColumns
|
|
439
|
+
.filter((c) => !c.primary)
|
|
440
|
+
.map(
|
|
441
|
+
(c) => ` case "${c.field}":
|
|
442
|
+
await tx\`UPDATE ${qTable} SET ${q(c.column)} = \${value} WHERE ${q(pkColumn)} = \${${pkField}}\`;
|
|
443
|
+
break;`,
|
|
444
|
+
)
|
|
445
|
+
.join("\n");
|
|
446
|
+
|
|
447
|
+
return ` /**
|
|
448
|
+
* Update an existing row by primary key. Fields left \`undefined\`
|
|
449
|
+
* in \`patch\` are not modified. Returns the updated row, or
|
|
450
|
+
* \`null\` when no row with the given primary key exists.
|
|
451
|
+
*
|
|
452
|
+
* Implementation detail: the dynamic SET is emitted as a sequence of
|
|
453
|
+
* single-column UPDATEs within a transaction. This keeps the SQL a
|
|
454
|
+
* well-formed tagged template across all three dialects at the cost
|
|
455
|
+
* of N round-trips for N patched fields. For hot write paths, drop
|
|
456
|
+
* to raw \`db\` in your slot.
|
|
457
|
+
*/
|
|
458
|
+
async update(
|
|
459
|
+
${pkField}: ${pascalName}["${pkField}"],
|
|
460
|
+
patch: Partial<Omit<${pascalName}, "${pkField}">>,
|
|
461
|
+
): Promise<${pascalName} | null> {
|
|
462
|
+
return db.transaction(async (tx) => {
|
|
463
|
+
const existing = await tx.one<${pascalName}>\`
|
|
464
|
+
SELECT ${selectList} FROM ${qTable} WHERE ${q(pkColumn)} = \${${pkField}}
|
|
465
|
+
\`;
|
|
466
|
+
if (!existing) return null;
|
|
467
|
+
for (const [field, value] of Object.entries(patch as Record<string, unknown>)) {
|
|
468
|
+
if (value === undefined) continue;
|
|
469
|
+
switch (field) {
|
|
470
|
+
${updateCases}
|
|
471
|
+
default:
|
|
472
|
+
// Unknown key — silently ignore. Contract validation at the
|
|
473
|
+
// slot layer should prevent this from reaching the repo.
|
|
474
|
+
break;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return tx.one<${pascalName}>\`
|
|
478
|
+
SELECT ${selectList} FROM ${qTable} WHERE ${q(pkColumn)} = \${${pkField}}
|
|
479
|
+
\`;
|
|
480
|
+
});
|
|
481
|
+
}`;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function deleteMethod(
|
|
485
|
+
pascalName: string,
|
|
486
|
+
qTable: string,
|
|
487
|
+
q: (n: string) => string,
|
|
488
|
+
pkColumn: string,
|
|
489
|
+
pkField: string,
|
|
490
|
+
_provider: SqlProvider,
|
|
491
|
+
): string {
|
|
492
|
+
// All three providers support DELETE; we return true iff at least one
|
|
493
|
+
// row was removed. Rather than rely on driver-specific affected-row
|
|
494
|
+
// reporting (`count` on PG, `changes()` on SQLite, `affectedRows` on
|
|
495
|
+
// MySQL) we do a cheap SELECT-then-DELETE so behavior is identical
|
|
496
|
+
// across dialects. Same correctness-over-perf trade-off as `update`:
|
|
497
|
+
// drop to raw `db` in a slot for hot delete paths.
|
|
498
|
+
return ` /**
|
|
499
|
+
* Delete a row by primary key. Returns \`true\` if a row was deleted,
|
|
500
|
+
* \`false\` if no row matched.
|
|
501
|
+
*/
|
|
502
|
+
async delete(${pkField}: ${pascalName}["${pkField}"]): Promise<boolean> {
|
|
503
|
+
const existing = await db.one\`SELECT 1 AS ok FROM ${qTable} WHERE ${q(pkColumn)} = \${${pkField}} LIMIT 1\`;
|
|
504
|
+
if (!existing) return false;
|
|
505
|
+
await db\`DELETE FROM ${qTable} WHERE ${q(pkColumn)} = \${${pkField}}\`;
|
|
506
|
+
return true;
|
|
507
|
+
}`;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// ============================================
|
|
511
|
+
// Column + table resolution
|
|
512
|
+
// ============================================
|
|
513
|
+
|
|
514
|
+
interface ResolvedColumn {
|
|
515
|
+
/** Field key the author wrote in `definition.fields` (e.g. `passwordHash`). */
|
|
516
|
+
field: string;
|
|
517
|
+
/** Column name in SQL (e.g. `password_hash`). */
|
|
518
|
+
column: string;
|
|
519
|
+
primary: boolean;
|
|
520
|
+
/** PK auto-generated by the DB → skip from INSERT column list. */
|
|
521
|
+
omitOnInsert: boolean;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function resolveColumns(
|
|
525
|
+
resource: ParsedResource,
|
|
526
|
+
persistence: ExtendedResourcePersistence,
|
|
527
|
+
): ResolvedColumn[] {
|
|
528
|
+
const { definition } = resource;
|
|
529
|
+
const overrides = persistence.fieldOverrides ?? {};
|
|
530
|
+
const declaredPk = resolveDeclaredPrimaryKey(persistence.primaryKey);
|
|
531
|
+
|
|
532
|
+
const columns: ResolvedColumn[] = [];
|
|
533
|
+
let pkCount = 0;
|
|
534
|
+
|
|
535
|
+
for (const [fieldKey, field] of Object.entries(definition.fields)) {
|
|
536
|
+
const override = overrides[fieldKey];
|
|
537
|
+
const column = override?.columnName ?? toSnakeCase(fieldKey);
|
|
538
|
+
// `primary` resolution mirrors `snapshot.ts`:
|
|
539
|
+
// - named by persistence.primaryKey, OR
|
|
540
|
+
// - field-level `primary: true` (via cast — Mandu's public
|
|
541
|
+
// ResourceField type predates this flag).
|
|
542
|
+
const declaredPkMatch = declaredPk !== undefined && declaredPk === fieldKey;
|
|
543
|
+
const fieldLevelPk = Boolean(
|
|
544
|
+
(field as { primary?: boolean }).primary,
|
|
545
|
+
);
|
|
546
|
+
const primary = declaredPkMatch || fieldLevelPk;
|
|
547
|
+
if (primary) pkCount++;
|
|
548
|
+
|
|
549
|
+
const omitOnInsert = primary && hasDbDefault(field, override);
|
|
550
|
+
|
|
551
|
+
columns.push({ field: fieldKey, column, primary, omitOnInsert });
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (pkCount === 0) {
|
|
555
|
+
throw new TypeError(
|
|
556
|
+
`Resource "${resource.resourceName}" has no primary-key field. Mark one with \`primary: true\` (via fieldOverrides) or declare \`options.persistence.primaryKey\`.`,
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
if (pkCount > 1) {
|
|
560
|
+
throw new TypeError(
|
|
561
|
+
`Resource "${resource.resourceName}" has ${pkCount} primary-key fields. Composite primary keys are not supported in v1.`,
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
return columns;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function resolveDeclaredPrimaryKey(
|
|
569
|
+
declared: ExtendedResourcePersistence["primaryKey"],
|
|
570
|
+
): string | undefined {
|
|
571
|
+
if (declared === undefined) return undefined;
|
|
572
|
+
if (typeof declared === "string") return declared;
|
|
573
|
+
return declared[0];
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function createMethodInputType(
|
|
577
|
+
pascalName: string,
|
|
578
|
+
columns: ResolvedColumn[],
|
|
579
|
+
): string {
|
|
580
|
+
const omittedFields = columns
|
|
581
|
+
.filter((c) => c.omitOnInsert)
|
|
582
|
+
.map((c) => JSON.stringify(c.field));
|
|
583
|
+
if (omittedFields.length === 0) return pascalName;
|
|
584
|
+
return `Omit<${pascalName}, ${omittedFields.join(" | ")}>`;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function hasDbDefault(
|
|
588
|
+
field: ResourceField,
|
|
589
|
+
override: FieldOverride | undefined,
|
|
590
|
+
): boolean {
|
|
591
|
+
return override?.default !== undefined || field.default !== undefined;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function escapeSqlTemplateText(text: string): string {
|
|
595
|
+
return text.replace(/`/g, "\\`");
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function resolveTableName(
|
|
599
|
+
resource: ParsedResource,
|
|
600
|
+
persistence: ExtendedResourcePersistence,
|
|
601
|
+
): string {
|
|
602
|
+
if (persistence.tableName) return persistence.tableName;
|
|
603
|
+
const options = resource.definition.options;
|
|
604
|
+
if (options?.pluralName) return options.pluralName;
|
|
605
|
+
if (options?.autoPlural === false) return resource.definition.name;
|
|
606
|
+
return pluralize(resource.definition.name);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Shared pluralizer — MUST match `snapshot.ts`. If the two diverge the
|
|
611
|
+
* repo's table name won't match the actual table → runtime SQL errors.
|
|
612
|
+
* Kept as a private copy (vs import) because `snapshot.ts` doesn't export
|
|
613
|
+
* its pluralizer; duplication is guarded by a test that compares both.
|
|
614
|
+
*/
|
|
615
|
+
function pluralize(singular: string): string {
|
|
616
|
+
if (/[^aeiou]y$/i.test(singular)) {
|
|
617
|
+
return singular.slice(0, -1) + "ies";
|
|
618
|
+
}
|
|
619
|
+
if (/(?:s|x|z|ch|sh)$/i.test(singular)) {
|
|
620
|
+
return singular + "es";
|
|
621
|
+
}
|
|
622
|
+
return singular + "s";
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function toPascalCase(str: string): string {
|
|
626
|
+
return str
|
|
627
|
+
.split(/[-_\s]/)
|
|
628
|
+
.map((part) => (part.length === 0 ? "" : part[0]!.toUpperCase() + part.slice(1)))
|
|
629
|
+
.join("");
|
|
630
|
+
}
|