@mandujs/core 0.53.3 → 0.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +654 -654
- package/package.json +2 -2
- 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 +394 -394
- package/src/a11y/types.ts +125 -125
- package/src/auth/__tests__/login.test.ts +1 -1
- package/src/auth/__tests__/password.test.ts +122 -122
- 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 +120 -120
- 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/bundler/__tests__/build-runner.ts +113 -113
- 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 +10 -10
- package/src/bundler/__tests__/hdr.test.ts +353 -353
- package/src/bundler/__tests__/hmr-client.test.ts +532 -532
- package/src/bundler/__tests__/manifest-schema.test.ts +266 -266
- package/src/bundler/__tests__/prod-smoke.test.ts +138 -138
- package/src/bundler/__tests__/reverse-import-graph.test.ts +519 -519
- 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/budget.ts +404 -404
- package/src/bundler/build.test.ts +179 -179
- package/src/bundler/build.ts +55 -55
- package/src/bundler/dev.ts +42 -42
- package/src/bundler/manifest-schema.ts +301 -301
- package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +263 -263
- 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 +155 -155
- 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/reverse-import-graph.ts +339 -339
- package/src/bundler/safe-build.test.ts +201 -201
- package/src/bundler/safe-build.ts +103 -103
- package/src/bundler/scenario-matrix.ts +229 -229
- package/src/bundler/types.ts +10 -10
- 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__/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 +340 -340
- package/src/client/prefetch-helper.ts +55 -55
- package/src/client/router.ts +11 -11
- package/src/client/runtime.ts +47 -47
- package/src/client/serialize.ts +404 -404
- 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.tsx +162 -162
- package/src/config/validate.ts +3 -3
- package/src/config/watcher.ts +311 -311
- package/src/constants.ts +40 -40
- package/src/content/collection.ts +8 -8
- 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/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/schema.ts +48 -48
- package/src/contract/types.ts +58 -58
- package/src/db/__tests__/db.test.ts +482 -482
- package/src/db/index.ts +138 -138
- 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 +182 -182
- 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__/webview-fallback.test.ts +254 -254
- package/src/desktop/__tests__/window.test.ts +248 -248
- 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/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/overlay.tsx +368 -368
- 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 +320 -320
- package/src/devtools/client/components/panel/network-panel.tsx +291 -291
- package/src/devtools/client/components/panel/panel-container.tsx +500 -500
- 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/client/persistence.ts +335 -335
- 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/server/source-context.ts +450 -450
- 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/devtools/worker/worker-manager.ts +410 -410
- package/src/diagnose/__tests__/checks.test.ts +451 -451
- package/src/diagnose/checks.ts +832 -832
- package/src/diagnose/index.ts +17 -17
- package/src/diagnose/run.ts +93 -93
- 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/result.ts +60 -60
- 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 +454 -454
- package/src/filling/auth.ts +308 -308
- package/src/filling/cookie-codec.ts +299 -299
- package/src/filling/deps.ts +265 -265
- 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/index.ts +3 -3
- 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/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/graph.ts +1 -1
- package/src/guard/healing.ts +36 -36
- 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/index.ts +105 -105
- package/src/index.ts +2 -2
- package/src/kitchen/api/agent-devtools-api.ts +779 -544
- package/src/kitchen/api/errors-grouping.ts +126 -0
- package/src/kitchen/kitchen-handler.ts +192 -63
- package/src/kitchen/kitchen-ui.ts +842 -464
- 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/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 +32 -32
- package/src/resource/ddl/__tests__/diff.test.ts +639 -639
- package/src/resource/ddl/__tests__/emit.test.ts +823 -823
- package/src/resource/ddl/__tests__/snapshot.test.ts +499 -499
- package/src/resource/ddl/emit.ts +559 -559
- 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/schema.ts +1 -1
- package/src/router/fs-patterns.test.ts +96 -96
- package/src/router/fs-routes.ts +6 -6
- 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__/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__/not-found.test.ts +152 -152
- 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/fast-refresh-runtime.ts +322 -322
- package/src/runtime/handler.ts +65 -65
- package/src/runtime/handlers.ts +300 -300
- 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/openapi-endpoint.ts +236 -236
- package/src/runtime/ppr.ts +74 -74
- package/src/runtime/registry.ts +171 -171
- package/src/runtime/router.ts +105 -105
- package/src/runtime/server.ts +67 -67
- package/src/runtime/shims.ts +48 -48
- package/src/runtime/trace.ts +144 -144
- 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/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/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/watcher.ts +18 -18
|
@@ -1,639 +1,639 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Phase 4c — Agent B tests for `diff.ts`.
|
|
3
|
-
*
|
|
4
|
-
* 20+ cases covering:
|
|
5
|
-
* - null-handling (first apply, never-null `next`)
|
|
6
|
-
* - cross-provider guard
|
|
7
|
-
* - every Change kind
|
|
8
|
-
* - deterministic cross-table ordering
|
|
9
|
-
* - deterministic within-table ordering
|
|
10
|
-
* - rename NEVER auto-detected — always drop + add
|
|
11
|
-
* - convenience `diffResources` wrapper
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { describe, test, expect } from "bun:test";
|
|
15
|
-
import type {
|
|
16
|
-
Change,
|
|
17
|
-
DdlFieldDef,
|
|
18
|
-
DdlIndex,
|
|
19
|
-
DdlResource,
|
|
20
|
-
Snapshot,
|
|
21
|
-
SqlProvider,
|
|
22
|
-
} from "../types";
|
|
23
|
-
import type { ParsedResource } from "../../parser";
|
|
24
|
-
import type { ResourceDefinition } from "../../schema";
|
|
25
|
-
import { diffSnapshots, diffResources } from "../diff";
|
|
26
|
-
|
|
27
|
-
// --------------------------------------------------------------------
|
|
28
|
-
// Builders — tiny + explicit so each test reads like a spec
|
|
29
|
-
// --------------------------------------------------------------------
|
|
30
|
-
|
|
31
|
-
function field(
|
|
32
|
-
name: string,
|
|
33
|
-
overrides: Partial<DdlFieldDef> = {}
|
|
34
|
-
): DdlFieldDef {
|
|
35
|
-
return {
|
|
36
|
-
name,
|
|
37
|
-
type: "string",
|
|
38
|
-
nullable: false,
|
|
39
|
-
primary: false,
|
|
40
|
-
unique: false,
|
|
41
|
-
indexed: false,
|
|
42
|
-
...overrides,
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function resource(
|
|
47
|
-
name: string,
|
|
48
|
-
fields: DdlFieldDef[] = [],
|
|
49
|
-
indexes: DdlIndex[] = []
|
|
50
|
-
): DdlResource {
|
|
51
|
-
return { name, fields, indexes };
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function snapshot(
|
|
55
|
-
resources: DdlResource[],
|
|
56
|
-
provider: SqlProvider = "postgres"
|
|
57
|
-
): Snapshot {
|
|
58
|
-
return {
|
|
59
|
-
version: 1,
|
|
60
|
-
provider,
|
|
61
|
-
resources,
|
|
62
|
-
generatedAt: "2026-04-17T00:00:00.000Z",
|
|
63
|
-
};
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const userPK: DdlFieldDef = field("id", { type: "uuid", primary: true });
|
|
67
|
-
|
|
68
|
-
// --------------------------------------------------------------------
|
|
69
|
-
// TC-1..3 — null handling
|
|
70
|
-
// --------------------------------------------------------------------
|
|
71
|
-
|
|
72
|
-
describe("diffSnapshots — null handling", () => {
|
|
73
|
-
test("1. diffSnapshots(null, empty snapshot) → []", () => {
|
|
74
|
-
expect(diffSnapshots(null, snapshot([]))).toEqual([]);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
test("2. diffSnapshots(null, {A}) → [create-table A]", () => {
|
|
78
|
-
const users = resource("users", [userPK]);
|
|
79
|
-
const out = diffSnapshots(null, snapshot([users]));
|
|
80
|
-
expect(out).toHaveLength(1);
|
|
81
|
-
expect(out[0]).toEqual({ kind: "create-table", resource: users });
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
test("3. diffSnapshots({A}, null) → throws (next may not be null)", () => {
|
|
85
|
-
const users = resource("users", [userPK]);
|
|
86
|
-
// @ts-expect-error — deliberately violating the contract
|
|
87
|
-
expect(() => diffSnapshots(snapshot([users]), null)).toThrow(
|
|
88
|
-
/next.*must be a Snapshot/
|
|
89
|
-
);
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
test("4. diffSnapshots({A}, empty) → [drop-table A]", () => {
|
|
93
|
-
const users = resource("users", [userPK]);
|
|
94
|
-
const out = diffSnapshots(snapshot([users]), snapshot([]));
|
|
95
|
-
expect(out).toEqual([{ kind: "drop-table", resourceName: "users" }]);
|
|
96
|
-
});
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
// --------------------------------------------------------------------
|
|
100
|
-
// TC-5 — cross-provider guard
|
|
101
|
-
// --------------------------------------------------------------------
|
|
102
|
-
|
|
103
|
-
describe("diffSnapshots — cross-provider guard", () => {
|
|
104
|
-
test("5. postgres → mysql snapshot diff throws TypeError", () => {
|
|
105
|
-
const pg = snapshot([resource("users", [userPK])], "postgres");
|
|
106
|
-
const my = snapshot([resource("users", [userPK])], "mysql");
|
|
107
|
-
expect(() => diffSnapshots(pg, my)).toThrow(/Cross-provider diff/);
|
|
108
|
-
});
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
// --------------------------------------------------------------------
|
|
112
|
-
// TC-6..10 — single-dimension column changes
|
|
113
|
-
// --------------------------------------------------------------------
|
|
114
|
-
|
|
115
|
-
describe("diffSnapshots — column-level changes", () => {
|
|
116
|
-
test("6. add a field → [add-column]", () => {
|
|
117
|
-
const before = snapshot([resource("users", [userPK])]);
|
|
118
|
-
const after = snapshot([resource("users", [userPK, field("email", { type: "email" })])]);
|
|
119
|
-
const out = diffSnapshots(before, after);
|
|
120
|
-
expect(out).toHaveLength(1);
|
|
121
|
-
expect(out[0]).toMatchObject({
|
|
122
|
-
kind: "add-column",
|
|
123
|
-
resourceName: "users",
|
|
124
|
-
field: { name: "email", type: "email" },
|
|
125
|
-
});
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
test("7. drop a field → [drop-column]", () => {
|
|
129
|
-
const before = snapshot([resource("users", [userPK, field("email", { type: "email" })])]);
|
|
130
|
-
const after = snapshot([resource("users", [userPK])]);
|
|
131
|
-
const out = diffSnapshots(before, after);
|
|
132
|
-
expect(out).toHaveLength(1);
|
|
133
|
-
expect(out[0]).toEqual({
|
|
134
|
-
kind: "drop-column",
|
|
135
|
-
resourceName: "users",
|
|
136
|
-
fieldName: "email",
|
|
137
|
-
});
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
test("8. change field type → [alter-column-type] with stub:true", () => {
|
|
141
|
-
const before = snapshot([
|
|
142
|
-
resource("users", [userPK, field("age", { type: "number" })]),
|
|
143
|
-
]);
|
|
144
|
-
const after = snapshot([
|
|
145
|
-
resource("users", [userPK, field("age", { type: "string" })]),
|
|
146
|
-
]);
|
|
147
|
-
const out = diffSnapshots(before, after);
|
|
148
|
-
expect(out).toHaveLength(1);
|
|
149
|
-
expect(out[0]).toEqual({
|
|
150
|
-
kind: "alter-column-type",
|
|
151
|
-
resourceName: "users",
|
|
152
|
-
fieldName: "age",
|
|
153
|
-
fromType: "number",
|
|
154
|
-
toType: "string",
|
|
155
|
-
stub: true,
|
|
156
|
-
});
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
test("9. flip nullable → [alter-column-nullable]", () => {
|
|
160
|
-
const before = snapshot([
|
|
161
|
-
resource("users", [userPK, field("bio", { type: "string", nullable: false })]),
|
|
162
|
-
]);
|
|
163
|
-
const after = snapshot([
|
|
164
|
-
resource("users", [userPK, field("bio", { type: "string", nullable: true })]),
|
|
165
|
-
]);
|
|
166
|
-
const out = diffSnapshots(before, after);
|
|
167
|
-
expect(out).toHaveLength(1);
|
|
168
|
-
expect(out[0]).toEqual({
|
|
169
|
-
kind: "alter-column-nullable",
|
|
170
|
-
resourceName: "users",
|
|
171
|
-
fieldName: "bio",
|
|
172
|
-
nullable: true,
|
|
173
|
-
});
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
test("10. change default → [alter-column-default]", () => {
|
|
177
|
-
const before = snapshot([
|
|
178
|
-
resource("users", [
|
|
179
|
-
userPK,
|
|
180
|
-
field("active", { type: "boolean", default: { kind: "literal", value: false } }),
|
|
181
|
-
]),
|
|
182
|
-
]);
|
|
183
|
-
const after = snapshot([
|
|
184
|
-
resource("users", [
|
|
185
|
-
userPK,
|
|
186
|
-
field("active", { type: "boolean", default: { kind: "literal", value: true } }),
|
|
187
|
-
]),
|
|
188
|
-
]);
|
|
189
|
-
const out = diffSnapshots(before, after);
|
|
190
|
-
expect(out).toHaveLength(1);
|
|
191
|
-
expect(out[0]).toEqual({
|
|
192
|
-
kind: "alter-column-default",
|
|
193
|
-
resourceName: "users",
|
|
194
|
-
fieldName: "active",
|
|
195
|
-
default: { kind: "literal", value: true },
|
|
196
|
-
});
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
test("removing default emits alter-column-default with no default", () => {
|
|
200
|
-
const before = snapshot([
|
|
201
|
-
resource("users", [
|
|
202
|
-
userPK,
|
|
203
|
-
field("active", { type: "boolean", default: { kind: "literal", value: true } }),
|
|
204
|
-
]),
|
|
205
|
-
]);
|
|
206
|
-
const after = snapshot([
|
|
207
|
-
resource("users", [userPK, field("active", { type: "boolean" })]),
|
|
208
|
-
]);
|
|
209
|
-
const out = diffSnapshots(before, after);
|
|
210
|
-
expect(out).toHaveLength(1);
|
|
211
|
-
expect(out[0]).toMatchObject({
|
|
212
|
-
kind: "alter-column-default",
|
|
213
|
-
resourceName: "users",
|
|
214
|
-
fieldName: "active",
|
|
215
|
-
});
|
|
216
|
-
// @ts-expect-error — discriminated narrow
|
|
217
|
-
expect(out[0].default).toBeUndefined();
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
test("identical snapshot → []", () => {
|
|
221
|
-
const s = snapshot([resource("users", [userPK, field("email", { type: "email" })])]);
|
|
222
|
-
expect(diffSnapshots(s, s)).toEqual([]);
|
|
223
|
-
});
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
// --------------------------------------------------------------------
|
|
227
|
-
// TC-11 — rename NEVER auto-detected
|
|
228
|
-
// --------------------------------------------------------------------
|
|
229
|
-
|
|
230
|
-
describe("diffSnapshots — rename policy", () => {
|
|
231
|
-
test("11. renamed field surfaces as drop + add (never rename)", () => {
|
|
232
|
-
const before = snapshot([
|
|
233
|
-
resource("users", [userPK, field("userName", { type: "string" })]),
|
|
234
|
-
]);
|
|
235
|
-
const after = snapshot([
|
|
236
|
-
resource("users", [userPK, field("displayName", { type: "string" })]),
|
|
237
|
-
]);
|
|
238
|
-
const out = diffSnapshots(before, after);
|
|
239
|
-
const kinds = out.map((c) => c.kind);
|
|
240
|
-
expect(kinds).toContain("drop-column");
|
|
241
|
-
expect(kinds).toContain("add-column");
|
|
242
|
-
expect(kinds).not.toContain("rename-column");
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
test("renamed table surfaces as drop + create (never rename)", () => {
|
|
246
|
-
const before = snapshot([resource("users", [userPK])]);
|
|
247
|
-
const after = snapshot([resource("accounts", [userPK])]);
|
|
248
|
-
const out = diffSnapshots(before, after);
|
|
249
|
-
const kinds = out.map((c) => c.kind);
|
|
250
|
-
expect(kinds).toContain("drop-table");
|
|
251
|
-
expect(kinds).toContain("create-table");
|
|
252
|
-
expect(kinds).not.toContain("rename-table");
|
|
253
|
-
});
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
// --------------------------------------------------------------------
|
|
257
|
-
// TC-12 — within-table order
|
|
258
|
-
// --------------------------------------------------------------------
|
|
259
|
-
|
|
260
|
-
describe("diffSnapshots — within-table order", () => {
|
|
261
|
-
test("12. multiple column changes on one table: drops → alters → adds", () => {
|
|
262
|
-
// Before: id, password, status (string)
|
|
263
|
-
// After: id, status (number), bio — drop `password`, alter `status` type, add `bio`
|
|
264
|
-
const before = snapshot([
|
|
265
|
-
resource("users", [
|
|
266
|
-
userPK,
|
|
267
|
-
field("password", { type: "string" }),
|
|
268
|
-
field("status", { type: "string" }),
|
|
269
|
-
]),
|
|
270
|
-
]);
|
|
271
|
-
const after = snapshot([
|
|
272
|
-
resource("users", [
|
|
273
|
-
userPK,
|
|
274
|
-
field("status", { type: "number" }),
|
|
275
|
-
field("bio", { type: "string" }),
|
|
276
|
-
]),
|
|
277
|
-
]);
|
|
278
|
-
const out = diffSnapshots(before, after);
|
|
279
|
-
const kinds = out.map((c) => c.kind);
|
|
280
|
-
// drop-column must precede alter-column-type; alter-column-type must precede add-column.
|
|
281
|
-
const dropIdx = kinds.indexOf("drop-column");
|
|
282
|
-
const alterIdx = kinds.indexOf("alter-column-type");
|
|
283
|
-
const addIdx = kinds.indexOf("add-column");
|
|
284
|
-
expect(dropIdx).toBeGreaterThanOrEqual(0);
|
|
285
|
-
expect(alterIdx).toBeGreaterThan(dropIdx);
|
|
286
|
-
expect(addIdx).toBeGreaterThan(alterIdx);
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
test("multiple drop-columns on the same table sort alphabetically", () => {
|
|
290
|
-
const before = snapshot([
|
|
291
|
-
resource("users", [
|
|
292
|
-
userPK,
|
|
293
|
-
field("zebra"),
|
|
294
|
-
field("apple"),
|
|
295
|
-
field("mango"),
|
|
296
|
-
]),
|
|
297
|
-
]);
|
|
298
|
-
const after = snapshot([resource("users", [userPK])]);
|
|
299
|
-
const out = diffSnapshots(before, after);
|
|
300
|
-
const dropNames = out
|
|
301
|
-
.filter((c) => c.kind === "drop-column")
|
|
302
|
-
.map((c) => (c as { fieldName: string }).fieldName);
|
|
303
|
-
expect(dropNames).toEqual(["apple", "mango", "zebra"]);
|
|
304
|
-
});
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
// --------------------------------------------------------------------
|
|
308
|
-
// TC-13..14 — indexes + create-table inline indexes
|
|
309
|
-
// --------------------------------------------------------------------
|
|
310
|
-
|
|
311
|
-
describe("diffSnapshots — indexes", () => {
|
|
312
|
-
test("13. drop table with index → [drop-index, drop-table] (drop-index first)", () => {
|
|
313
|
-
const idx: DdlIndex = { name: "idx_users_email", fields: ["email"], unique: true };
|
|
314
|
-
const before = snapshot([
|
|
315
|
-
resource("users", [userPK, field("email", { type: "email" })], [idx]),
|
|
316
|
-
]);
|
|
317
|
-
const after = snapshot([]);
|
|
318
|
-
const out = diffSnapshots(before, after);
|
|
319
|
-
expect(out.map((c) => c.kind)).toEqual(["drop-index", "drop-table"]);
|
|
320
|
-
});
|
|
321
|
-
|
|
322
|
-
test("14. create table with declared index → single [create-table] (no separate add-index)", () => {
|
|
323
|
-
const idx: DdlIndex = { name: "idx_users_email", fields: ["email"], unique: true };
|
|
324
|
-
const after = snapshot([
|
|
325
|
-
resource("users", [userPK, field("email", { type: "email" })], [idx]),
|
|
326
|
-
]);
|
|
327
|
-
const out = diffSnapshots(null, after);
|
|
328
|
-
expect(out.map((c) => c.kind)).toEqual(["create-table"]);
|
|
329
|
-
// The inline index lives on the CREATE TABLE's resource payload —
|
|
330
|
-
// Agent A's emit is responsible for CREATE INDEX inside CREATE TABLE.
|
|
331
|
-
expect((out[0] as { resource: DdlResource }).resource.indexes).toHaveLength(1);
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
test("20. adding indexed flag to an existing column via a new index → [add-index]", () => {
|
|
335
|
-
const idx: DdlIndex = { name: "idx_users_email", fields: ["email"], unique: false };
|
|
336
|
-
const before = snapshot([
|
|
337
|
-
resource("users", [userPK, field("email", { type: "email" })], []),
|
|
338
|
-
]);
|
|
339
|
-
const after = snapshot([
|
|
340
|
-
resource("users", [userPK, field("email", { type: "email" })], [idx]),
|
|
341
|
-
]);
|
|
342
|
-
const out = diffSnapshots(before, after);
|
|
343
|
-
expect(out).toHaveLength(1);
|
|
344
|
-
expect(out[0]).toEqual({ kind: "add-index", resourceName: "users", index: idx });
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
test("changing an index's shape (e.g. unique flip) → drop-index + add-index", () => {
|
|
348
|
-
const before = snapshot([
|
|
349
|
-
resource("users", [userPK, field("email", { type: "email" })], [
|
|
350
|
-
{ name: "idx_users_email", fields: ["email"], unique: false },
|
|
351
|
-
]),
|
|
352
|
-
]);
|
|
353
|
-
const after = snapshot([
|
|
354
|
-
resource("users", [userPK, field("email", { type: "email" })], [
|
|
355
|
-
{ name: "idx_users_email", fields: ["email"], unique: true },
|
|
356
|
-
]),
|
|
357
|
-
]);
|
|
358
|
-
const out = diffSnapshots(before, after);
|
|
359
|
-
const kinds = out.map((c) => c.kind);
|
|
360
|
-
expect(kinds).toContain("drop-index");
|
|
361
|
-
expect(kinds).toContain("add-index");
|
|
362
|
-
// drop-index precedes add-index in the canonical order.
|
|
363
|
-
expect(kinds.indexOf("drop-index")).toBeLessThan(kinds.indexOf("add-index"));
|
|
364
|
-
});
|
|
365
|
-
});
|
|
366
|
-
|
|
367
|
-
// --------------------------------------------------------------------
|
|
368
|
-
// TC-15..16 — full-order determinism
|
|
369
|
-
// --------------------------------------------------------------------
|
|
370
|
-
|
|
371
|
-
describe("diffSnapshots — determinism + cross-table order", () => {
|
|
372
|
-
test("15. identical inputs → byte-identical Change[]", () => {
|
|
373
|
-
const before = snapshot([
|
|
374
|
-
resource("users", [userPK, field("email", { type: "email" })]),
|
|
375
|
-
resource("posts", [userPK, field("title")]),
|
|
376
|
-
]);
|
|
377
|
-
const after = snapshot([
|
|
378
|
-
resource("users", [userPK, field("email", { type: "email" }), field("bio")]),
|
|
379
|
-
resource("posts", [userPK, field("title"), field("published", { type: "boolean" })]),
|
|
380
|
-
]);
|
|
381
|
-
const a = diffSnapshots(before, after);
|
|
382
|
-
const b = diffSnapshots(before, after);
|
|
383
|
-
expect(JSON.stringify(a)).toBe(JSON.stringify(b));
|
|
384
|
-
});
|
|
385
|
-
|
|
386
|
-
test("16. all 5 kinds in cross-table order: drop-index, drop-table, create-table, <per-table>, add-index", () => {
|
|
387
|
-
// Scenario:
|
|
388
|
-
// old: zebra_table (drops, has an index), kept_table (has an index to drop, a column to alter)
|
|
389
|
-
// new: kept_table (altered, with new index), apple_table (new)
|
|
390
|
-
const oldSnap = snapshot([
|
|
391
|
-
resource("zebra_table", [userPK, field("z")], [
|
|
392
|
-
{ name: "idx_zebra_z", fields: ["z"], unique: false },
|
|
393
|
-
]),
|
|
394
|
-
resource("kept_table", [
|
|
395
|
-
userPK,
|
|
396
|
-
field("active", {
|
|
397
|
-
type: "boolean",
|
|
398
|
-
default: { kind: "literal", value: false },
|
|
399
|
-
}),
|
|
400
|
-
], [{ name: "idx_kept_old", fields: ["active"], unique: false }]),
|
|
401
|
-
]);
|
|
402
|
-
const newSnap = snapshot([
|
|
403
|
-
resource("apple_table", [userPK]),
|
|
404
|
-
resource("kept_table", [
|
|
405
|
-
userPK,
|
|
406
|
-
field("active", {
|
|
407
|
-
type: "boolean",
|
|
408
|
-
default: { kind: "literal", value: true },
|
|
409
|
-
}),
|
|
410
|
-
], [{ name: "idx_kept_new", fields: ["active"], unique: false }]),
|
|
411
|
-
]);
|
|
412
|
-
const out = diffSnapshots(oldSnap, newSnap);
|
|
413
|
-
const kinds = out.map((c) => c.kind);
|
|
414
|
-
// Expected order (with specifics):
|
|
415
|
-
// drop-index idx_kept_old, drop-index idx_zebra_z,
|
|
416
|
-
// drop-table zebra_table,
|
|
417
|
-
// create-table apple_table,
|
|
418
|
-
// alter-column-default on kept_table.active,
|
|
419
|
-
// add-index idx_kept_new
|
|
420
|
-
expect(kinds).toEqual([
|
|
421
|
-
"drop-index",
|
|
422
|
-
"drop-index",
|
|
423
|
-
"drop-table",
|
|
424
|
-
"create-table",
|
|
425
|
-
"alter-column-default",
|
|
426
|
-
"add-index",
|
|
427
|
-
]);
|
|
428
|
-
});
|
|
429
|
-
});
|
|
430
|
-
|
|
431
|
-
// --------------------------------------------------------------------
|
|
432
|
-
// TC-17 — multiple alter kinds on the same column
|
|
433
|
-
// --------------------------------------------------------------------
|
|
434
|
-
|
|
435
|
-
describe("diffSnapshots — multi-kind alters", () => {
|
|
436
|
-
test("17. nullable + default both change → both Changes emit in sub-order (type, nullable, default)", () => {
|
|
437
|
-
const before = snapshot([
|
|
438
|
-
resource("users", [
|
|
439
|
-
userPK,
|
|
440
|
-
field("bio", {
|
|
441
|
-
type: "string",
|
|
442
|
-
nullable: false,
|
|
443
|
-
default: { kind: "literal", value: "" },
|
|
444
|
-
}),
|
|
445
|
-
]),
|
|
446
|
-
]);
|
|
447
|
-
const after = snapshot([
|
|
448
|
-
resource("users", [
|
|
449
|
-
userPK,
|
|
450
|
-
field("bio", {
|
|
451
|
-
type: "string",
|
|
452
|
-
nullable: true,
|
|
453
|
-
default: { kind: "null" },
|
|
454
|
-
}),
|
|
455
|
-
]),
|
|
456
|
-
]);
|
|
457
|
-
const out = diffSnapshots(before, after);
|
|
458
|
-
const kinds = out.map((c) => c.kind);
|
|
459
|
-
expect(kinds).toEqual(["alter-column-nullable", "alter-column-default"]);
|
|
460
|
-
});
|
|
461
|
-
|
|
462
|
-
test("type + nullable change → type precedes nullable", () => {
|
|
463
|
-
const before = snapshot([
|
|
464
|
-
resource("users", [userPK, field("x", { type: "string", nullable: false })]),
|
|
465
|
-
]);
|
|
466
|
-
const after = snapshot([
|
|
467
|
-
resource("users", [userPK, field("x", { type: "number", nullable: true })]),
|
|
468
|
-
]);
|
|
469
|
-
const out = diffSnapshots(before, after);
|
|
470
|
-
expect(out.map((c) => c.kind)).toEqual([
|
|
471
|
-
"alter-column-type",
|
|
472
|
-
"alter-column-nullable",
|
|
473
|
-
]);
|
|
474
|
-
});
|
|
475
|
-
});
|
|
476
|
-
|
|
477
|
-
// --------------------------------------------------------------------
|
|
478
|
-
// TC-18 — add-column on new PK field
|
|
479
|
-
// --------------------------------------------------------------------
|
|
480
|
-
|
|
481
|
-
describe("diffSnapshots — primary-key fields in add-column", () => {
|
|
482
|
-
test("18. new primary field → add-column carries primary:true on its field payload", () => {
|
|
483
|
-
const before = snapshot([resource("users", [field("tmp", { type: "string" })])]);
|
|
484
|
-
const after = snapshot([
|
|
485
|
-
resource("users", [
|
|
486
|
-
field("tmp", { type: "string" }),
|
|
487
|
-
field("id", { type: "uuid", primary: true }),
|
|
488
|
-
]),
|
|
489
|
-
]);
|
|
490
|
-
const out = diffSnapshots(before, after);
|
|
491
|
-
const add = out.find((c) => c.kind === "add-column")! as Extract<
|
|
492
|
-
Change,
|
|
493
|
-
{ kind: "add-column" }
|
|
494
|
-
>;
|
|
495
|
-
expect(add.field.primary).toBe(true);
|
|
496
|
-
});
|
|
497
|
-
});
|
|
498
|
-
|
|
499
|
-
// --------------------------------------------------------------------
|
|
500
|
-
// TC-19 — unique flip policy
|
|
501
|
-
// --------------------------------------------------------------------
|
|
502
|
-
|
|
503
|
-
describe("diffSnapshots — unique flip policy (v1)", () => {
|
|
504
|
-
test("19. flipping unique from false→true on a column emits alter-column-type stub (v1 workaround)", () => {
|
|
505
|
-
// Policy: v1 cannot express "add UNIQUE constraint" as a first-class Change.
|
|
506
|
-
// The correct future-facing response is an add-index with unique:true
|
|
507
|
-
// — but that index has no stable name until the user provides one. So
|
|
508
|
-
// v1 surfaces the flip as an alter-column-type stub so the user is
|
|
509
|
-
// forced to edit the generated SQL manually.
|
|
510
|
-
const before = snapshot([
|
|
511
|
-
resource("users", [userPK, field("email", { type: "email", unique: false })]),
|
|
512
|
-
]);
|
|
513
|
-
const after = snapshot([
|
|
514
|
-
resource("users", [userPK, field("email", { type: "email", unique: true })]),
|
|
515
|
-
]);
|
|
516
|
-
const out = diffSnapshots(before, after);
|
|
517
|
-
// We accept either a stub (today's v1 behavior: ignore unique flip since
|
|
518
|
-
// emit doesn't produce a UNIQUE constraint change) OR a dedicated Change.
|
|
519
|
-
// Document the v1 reality: the diff engine does not emit a dedicated
|
|
520
|
-
// Change for a unique flag flip. The user must add an explicit
|
|
521
|
-
// persistence.indexes entry with unique:true to get a migration.
|
|
522
|
-
const kinds = out.map((c) => c.kind);
|
|
523
|
-
// v1 choice: diff engine is silent; user must opt in explicitly via an
|
|
524
|
-
// indexes entry. This test documents that reality so it doesn't
|
|
525
|
-
// regress silently.
|
|
526
|
-
expect(kinds.includes("alter-column-type")).toBe(false);
|
|
527
|
-
expect(out).toEqual([]);
|
|
528
|
-
});
|
|
529
|
-
});
|
|
530
|
-
|
|
531
|
-
// --------------------------------------------------------------------
|
|
532
|
-
// TC-21 — convenience wrapper
|
|
533
|
-
// --------------------------------------------------------------------
|
|
534
|
-
|
|
535
|
-
describe("diffResources convenience wrapper", () => {
|
|
536
|
-
test("21. diffResources(parsed, null) computes next snapshot + create-table changes", () => {
|
|
537
|
-
const def: ResourceDefinition = {
|
|
538
|
-
name: "user",
|
|
539
|
-
fields: { id: { type: "uuid", required: true, primary: true } as any },
|
|
540
|
-
options: { persistence: { provider: "postgres" } } as any,
|
|
541
|
-
};
|
|
542
|
-
const parsed: ParsedResource = {
|
|
543
|
-
definition: def,
|
|
544
|
-
filePath: "/virtual/user.resource.ts",
|
|
545
|
-
fileName: "user",
|
|
546
|
-
resourceName: "user",
|
|
547
|
-
};
|
|
548
|
-
const { snapshot: snap, changes } = diffResources([parsed], null);
|
|
549
|
-
expect(snap.resources).toHaveLength(1);
|
|
550
|
-
expect(snap.resources[0].name).toBe("users");
|
|
551
|
-
expect(changes).toHaveLength(1);
|
|
552
|
-
expect(changes[0].kind).toBe("create-table");
|
|
553
|
-
});
|
|
554
|
-
|
|
555
|
-
test("diffResources roundtrips through an applied snapshot", () => {
|
|
556
|
-
const def: ResourceDefinition = {
|
|
557
|
-
name: "user",
|
|
558
|
-
fields: {
|
|
559
|
-
id: { type: "uuid", required: true, primary: true } as any,
|
|
560
|
-
email: { type: "email", required: true },
|
|
561
|
-
},
|
|
562
|
-
options: { persistence: { provider: "postgres" } } as any,
|
|
563
|
-
};
|
|
564
|
-
const parsed: ParsedResource = {
|
|
565
|
-
definition: def,
|
|
566
|
-
filePath: "/virtual/user.resource.ts",
|
|
567
|
-
fileName: "user",
|
|
568
|
-
resourceName: "user",
|
|
569
|
-
};
|
|
570
|
-
// First run: applied=null → create-table.
|
|
571
|
-
const r1 = diffResources([parsed], null);
|
|
572
|
-
expect(r1.changes[0].kind).toBe("create-table");
|
|
573
|
-
// Second run: applied=r1.snapshot → no change.
|
|
574
|
-
const r2 = diffResources([parsed], r1.snapshot);
|
|
575
|
-
expect(r2.changes).toEqual([]);
|
|
576
|
-
});
|
|
577
|
-
|
|
578
|
-
test("diffResources propagates snapshot errors (e.g. missing PK)", () => {
|
|
579
|
-
const def: ResourceDefinition = {
|
|
580
|
-
name: "user",
|
|
581
|
-
fields: { name: { type: "string", required: true } },
|
|
582
|
-
options: { persistence: { provider: "postgres" } } as any,
|
|
583
|
-
};
|
|
584
|
-
const parsed: ParsedResource = {
|
|
585
|
-
definition: def,
|
|
586
|
-
filePath: "/virtual/user.resource.ts",
|
|
587
|
-
fileName: "user",
|
|
588
|
-
resourceName: "user",
|
|
589
|
-
};
|
|
590
|
-
expect(() => diffResources([parsed], null)).toThrow(/exactly one primary key/);
|
|
591
|
-
});
|
|
592
|
-
});
|
|
593
|
-
|
|
594
|
-
// --------------------------------------------------------------------
|
|
595
|
-
// Stress — many changes at once
|
|
596
|
-
// --------------------------------------------------------------------
|
|
597
|
-
|
|
598
|
-
describe("diffSnapshots — stress ordering", () => {
|
|
599
|
-
test("cross-table: drop-index always first, add-index always last", () => {
|
|
600
|
-
const before = snapshot([
|
|
601
|
-
resource(
|
|
602
|
-
"users",
|
|
603
|
-
[userPK, field("a"), field("b")],
|
|
604
|
-
[{ name: "idx_users_a", fields: ["a"], unique: false }]
|
|
605
|
-
),
|
|
606
|
-
resource("posts", [userPK, field("title")]),
|
|
607
|
-
]);
|
|
608
|
-
const after = snapshot([
|
|
609
|
-
resource(
|
|
610
|
-
"users",
|
|
611
|
-
[userPK, field("c"), field("b")],
|
|
612
|
-
[
|
|
613
|
-
{ name: "idx_users_b", fields: ["b"], unique: false },
|
|
614
|
-
]
|
|
615
|
-
),
|
|
616
|
-
resource(
|
|
617
|
-
"posts",
|
|
618
|
-
[userPK, field("title"), field("slug")],
|
|
619
|
-
[{ name: "idx_posts_slug", fields: ["slug"], unique: true }]
|
|
620
|
-
),
|
|
621
|
-
]);
|
|
622
|
-
const out = diffSnapshots(before, after);
|
|
623
|
-
const kinds = out.map((c) => c.kind);
|
|
624
|
-
|
|
625
|
-
const firstDropIdx = kinds.indexOf("drop-index");
|
|
626
|
-
const lastDropIdx = kinds.lastIndexOf("drop-index");
|
|
627
|
-
const firstAddIdx = kinds.indexOf("add-index");
|
|
628
|
-
const lastAddIdx = kinds.lastIndexOf("add-index");
|
|
629
|
-
|
|
630
|
-
expect(firstDropIdx).toBe(0);
|
|
631
|
-
// All drop-indexes precede any other kind.
|
|
632
|
-
expect(lastDropIdx).toBeLessThan(
|
|
633
|
-
kinds.findIndex((k, i) => i > lastDropIdx && k !== "drop-index")
|
|
634
|
-
);
|
|
635
|
-
// All add-indexes are at the end — last index equals array length - 1.
|
|
636
|
-
expect(lastAddIdx).toBe(kinds.length - 1);
|
|
637
|
-
expect(firstAddIdx).toBeGreaterThan(firstDropIdx);
|
|
638
|
-
});
|
|
639
|
-
});
|
|
1
|
+
/**
|
|
2
|
+
* Phase 4c — Agent B tests for `diff.ts`.
|
|
3
|
+
*
|
|
4
|
+
* 20+ cases covering:
|
|
5
|
+
* - null-handling (first apply, never-null `next`)
|
|
6
|
+
* - cross-provider guard
|
|
7
|
+
* - every Change kind
|
|
8
|
+
* - deterministic cross-table ordering
|
|
9
|
+
* - deterministic within-table ordering
|
|
10
|
+
* - rename NEVER auto-detected — always drop + add
|
|
11
|
+
* - convenience `diffResources` wrapper
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, test, expect } from "bun:test";
|
|
15
|
+
import type {
|
|
16
|
+
Change,
|
|
17
|
+
DdlFieldDef,
|
|
18
|
+
DdlIndex,
|
|
19
|
+
DdlResource,
|
|
20
|
+
Snapshot,
|
|
21
|
+
SqlProvider,
|
|
22
|
+
} from "../types";
|
|
23
|
+
import type { ParsedResource } from "../../parser";
|
|
24
|
+
import type { ResourceDefinition } from "../../schema";
|
|
25
|
+
import { diffSnapshots, diffResources } from "../diff";
|
|
26
|
+
|
|
27
|
+
// --------------------------------------------------------------------
|
|
28
|
+
// Builders — tiny + explicit so each test reads like a spec
|
|
29
|
+
// --------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
function field(
|
|
32
|
+
name: string,
|
|
33
|
+
overrides: Partial<DdlFieldDef> = {}
|
|
34
|
+
): DdlFieldDef {
|
|
35
|
+
return {
|
|
36
|
+
name,
|
|
37
|
+
type: "string",
|
|
38
|
+
nullable: false,
|
|
39
|
+
primary: false,
|
|
40
|
+
unique: false,
|
|
41
|
+
indexed: false,
|
|
42
|
+
...overrides,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function resource(
|
|
47
|
+
name: string,
|
|
48
|
+
fields: DdlFieldDef[] = [],
|
|
49
|
+
indexes: DdlIndex[] = []
|
|
50
|
+
): DdlResource {
|
|
51
|
+
return { name, fields, indexes };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function snapshot(
|
|
55
|
+
resources: DdlResource[],
|
|
56
|
+
provider: SqlProvider = "postgres"
|
|
57
|
+
): Snapshot {
|
|
58
|
+
return {
|
|
59
|
+
version: 1,
|
|
60
|
+
provider,
|
|
61
|
+
resources,
|
|
62
|
+
generatedAt: "2026-04-17T00:00:00.000Z",
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const userPK: DdlFieldDef = field("id", { type: "uuid", primary: true });
|
|
67
|
+
|
|
68
|
+
// --------------------------------------------------------------------
|
|
69
|
+
// TC-1..3 — null handling
|
|
70
|
+
// --------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
describe("diffSnapshots — null handling", () => {
|
|
73
|
+
test("1. diffSnapshots(null, empty snapshot) → []", () => {
|
|
74
|
+
expect(diffSnapshots(null, snapshot([]))).toEqual([]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("2. diffSnapshots(null, {A}) → [create-table A]", () => {
|
|
78
|
+
const users = resource("users", [userPK]);
|
|
79
|
+
const out = diffSnapshots(null, snapshot([users]));
|
|
80
|
+
expect(out).toHaveLength(1);
|
|
81
|
+
expect(out[0]).toEqual({ kind: "create-table", resource: users });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("3. diffSnapshots({A}, null) → throws (next may not be null)", () => {
|
|
85
|
+
const users = resource("users", [userPK]);
|
|
86
|
+
// @ts-expect-error — deliberately violating the contract
|
|
87
|
+
expect(() => diffSnapshots(snapshot([users]), null)).toThrow(
|
|
88
|
+
/next.*must be a Snapshot/
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("4. diffSnapshots({A}, empty) → [drop-table A]", () => {
|
|
93
|
+
const users = resource("users", [userPK]);
|
|
94
|
+
const out = diffSnapshots(snapshot([users]), snapshot([]));
|
|
95
|
+
expect(out).toEqual([{ kind: "drop-table", resourceName: "users" }]);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// --------------------------------------------------------------------
|
|
100
|
+
// TC-5 — cross-provider guard
|
|
101
|
+
// --------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
describe("diffSnapshots — cross-provider guard", () => {
|
|
104
|
+
test("5. postgres → mysql snapshot diff throws TypeError", () => {
|
|
105
|
+
const pg = snapshot([resource("users", [userPK])], "postgres");
|
|
106
|
+
const my = snapshot([resource("users", [userPK])], "mysql");
|
|
107
|
+
expect(() => diffSnapshots(pg, my)).toThrow(/Cross-provider diff/);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// --------------------------------------------------------------------
|
|
112
|
+
// TC-6..10 — single-dimension column changes
|
|
113
|
+
// --------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
describe("diffSnapshots — column-level changes", () => {
|
|
116
|
+
test("6. add a field → [add-column]", () => {
|
|
117
|
+
const before = snapshot([resource("users", [userPK])]);
|
|
118
|
+
const after = snapshot([resource("users", [userPK, field("email", { type: "email" })])]);
|
|
119
|
+
const out = diffSnapshots(before, after);
|
|
120
|
+
expect(out).toHaveLength(1);
|
|
121
|
+
expect(out[0]).toMatchObject({
|
|
122
|
+
kind: "add-column",
|
|
123
|
+
resourceName: "users",
|
|
124
|
+
field: { name: "email", type: "email" },
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("7. drop a field → [drop-column]", () => {
|
|
129
|
+
const before = snapshot([resource("users", [userPK, field("email", { type: "email" })])]);
|
|
130
|
+
const after = snapshot([resource("users", [userPK])]);
|
|
131
|
+
const out = diffSnapshots(before, after);
|
|
132
|
+
expect(out).toHaveLength(1);
|
|
133
|
+
expect(out[0]).toEqual({
|
|
134
|
+
kind: "drop-column",
|
|
135
|
+
resourceName: "users",
|
|
136
|
+
fieldName: "email",
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("8. change field type → [alter-column-type] with stub:true", () => {
|
|
141
|
+
const before = snapshot([
|
|
142
|
+
resource("users", [userPK, field("age", { type: "number" })]),
|
|
143
|
+
]);
|
|
144
|
+
const after = snapshot([
|
|
145
|
+
resource("users", [userPK, field("age", { type: "string" })]),
|
|
146
|
+
]);
|
|
147
|
+
const out = diffSnapshots(before, after);
|
|
148
|
+
expect(out).toHaveLength(1);
|
|
149
|
+
expect(out[0]).toEqual({
|
|
150
|
+
kind: "alter-column-type",
|
|
151
|
+
resourceName: "users",
|
|
152
|
+
fieldName: "age",
|
|
153
|
+
fromType: "number",
|
|
154
|
+
toType: "string",
|
|
155
|
+
stub: true,
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("9. flip nullable → [alter-column-nullable]", () => {
|
|
160
|
+
const before = snapshot([
|
|
161
|
+
resource("users", [userPK, field("bio", { type: "string", nullable: false })]),
|
|
162
|
+
]);
|
|
163
|
+
const after = snapshot([
|
|
164
|
+
resource("users", [userPK, field("bio", { type: "string", nullable: true })]),
|
|
165
|
+
]);
|
|
166
|
+
const out = diffSnapshots(before, after);
|
|
167
|
+
expect(out).toHaveLength(1);
|
|
168
|
+
expect(out[0]).toEqual({
|
|
169
|
+
kind: "alter-column-nullable",
|
|
170
|
+
resourceName: "users",
|
|
171
|
+
fieldName: "bio",
|
|
172
|
+
nullable: true,
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("10. change default → [alter-column-default]", () => {
|
|
177
|
+
const before = snapshot([
|
|
178
|
+
resource("users", [
|
|
179
|
+
userPK,
|
|
180
|
+
field("active", { type: "boolean", default: { kind: "literal", value: false } }),
|
|
181
|
+
]),
|
|
182
|
+
]);
|
|
183
|
+
const after = snapshot([
|
|
184
|
+
resource("users", [
|
|
185
|
+
userPK,
|
|
186
|
+
field("active", { type: "boolean", default: { kind: "literal", value: true } }),
|
|
187
|
+
]),
|
|
188
|
+
]);
|
|
189
|
+
const out = diffSnapshots(before, after);
|
|
190
|
+
expect(out).toHaveLength(1);
|
|
191
|
+
expect(out[0]).toEqual({
|
|
192
|
+
kind: "alter-column-default",
|
|
193
|
+
resourceName: "users",
|
|
194
|
+
fieldName: "active",
|
|
195
|
+
default: { kind: "literal", value: true },
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("removing default emits alter-column-default with no default", () => {
|
|
200
|
+
const before = snapshot([
|
|
201
|
+
resource("users", [
|
|
202
|
+
userPK,
|
|
203
|
+
field("active", { type: "boolean", default: { kind: "literal", value: true } }),
|
|
204
|
+
]),
|
|
205
|
+
]);
|
|
206
|
+
const after = snapshot([
|
|
207
|
+
resource("users", [userPK, field("active", { type: "boolean" })]),
|
|
208
|
+
]);
|
|
209
|
+
const out = diffSnapshots(before, after);
|
|
210
|
+
expect(out).toHaveLength(1);
|
|
211
|
+
expect(out[0]).toMatchObject({
|
|
212
|
+
kind: "alter-column-default",
|
|
213
|
+
resourceName: "users",
|
|
214
|
+
fieldName: "active",
|
|
215
|
+
});
|
|
216
|
+
// @ts-expect-error — discriminated narrow
|
|
217
|
+
expect(out[0].default).toBeUndefined();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("identical snapshot → []", () => {
|
|
221
|
+
const s = snapshot([resource("users", [userPK, field("email", { type: "email" })])]);
|
|
222
|
+
expect(diffSnapshots(s, s)).toEqual([]);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// --------------------------------------------------------------------
|
|
227
|
+
// TC-11 — rename NEVER auto-detected
|
|
228
|
+
// --------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
describe("diffSnapshots — rename policy", () => {
|
|
231
|
+
test("11. renamed field surfaces as drop + add (never rename)", () => {
|
|
232
|
+
const before = snapshot([
|
|
233
|
+
resource("users", [userPK, field("userName", { type: "string" })]),
|
|
234
|
+
]);
|
|
235
|
+
const after = snapshot([
|
|
236
|
+
resource("users", [userPK, field("displayName", { type: "string" })]),
|
|
237
|
+
]);
|
|
238
|
+
const out = diffSnapshots(before, after);
|
|
239
|
+
const kinds = out.map((c) => c.kind);
|
|
240
|
+
expect(kinds).toContain("drop-column");
|
|
241
|
+
expect(kinds).toContain("add-column");
|
|
242
|
+
expect(kinds).not.toContain("rename-column");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("renamed table surfaces as drop + create (never rename)", () => {
|
|
246
|
+
const before = snapshot([resource("users", [userPK])]);
|
|
247
|
+
const after = snapshot([resource("accounts", [userPK])]);
|
|
248
|
+
const out = diffSnapshots(before, after);
|
|
249
|
+
const kinds = out.map((c) => c.kind);
|
|
250
|
+
expect(kinds).toContain("drop-table");
|
|
251
|
+
expect(kinds).toContain("create-table");
|
|
252
|
+
expect(kinds).not.toContain("rename-table");
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// --------------------------------------------------------------------
|
|
257
|
+
// TC-12 — within-table order
|
|
258
|
+
// --------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
describe("diffSnapshots — within-table order", () => {
|
|
261
|
+
test("12. multiple column changes on one table: drops → alters → adds", () => {
|
|
262
|
+
// Before: id, password, status (string)
|
|
263
|
+
// After: id, status (number), bio — drop `password`, alter `status` type, add `bio`
|
|
264
|
+
const before = snapshot([
|
|
265
|
+
resource("users", [
|
|
266
|
+
userPK,
|
|
267
|
+
field("password", { type: "string" }),
|
|
268
|
+
field("status", { type: "string" }),
|
|
269
|
+
]),
|
|
270
|
+
]);
|
|
271
|
+
const after = snapshot([
|
|
272
|
+
resource("users", [
|
|
273
|
+
userPK,
|
|
274
|
+
field("status", { type: "number" }),
|
|
275
|
+
field("bio", { type: "string" }),
|
|
276
|
+
]),
|
|
277
|
+
]);
|
|
278
|
+
const out = diffSnapshots(before, after);
|
|
279
|
+
const kinds = out.map((c) => c.kind);
|
|
280
|
+
// drop-column must precede alter-column-type; alter-column-type must precede add-column.
|
|
281
|
+
const dropIdx = kinds.indexOf("drop-column");
|
|
282
|
+
const alterIdx = kinds.indexOf("alter-column-type");
|
|
283
|
+
const addIdx = kinds.indexOf("add-column");
|
|
284
|
+
expect(dropIdx).toBeGreaterThanOrEqual(0);
|
|
285
|
+
expect(alterIdx).toBeGreaterThan(dropIdx);
|
|
286
|
+
expect(addIdx).toBeGreaterThan(alterIdx);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("multiple drop-columns on the same table sort alphabetically", () => {
|
|
290
|
+
const before = snapshot([
|
|
291
|
+
resource("users", [
|
|
292
|
+
userPK,
|
|
293
|
+
field("zebra"),
|
|
294
|
+
field("apple"),
|
|
295
|
+
field("mango"),
|
|
296
|
+
]),
|
|
297
|
+
]);
|
|
298
|
+
const after = snapshot([resource("users", [userPK])]);
|
|
299
|
+
const out = diffSnapshots(before, after);
|
|
300
|
+
const dropNames = out
|
|
301
|
+
.filter((c) => c.kind === "drop-column")
|
|
302
|
+
.map((c) => (c as { fieldName: string }).fieldName);
|
|
303
|
+
expect(dropNames).toEqual(["apple", "mango", "zebra"]);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// --------------------------------------------------------------------
|
|
308
|
+
// TC-13..14 — indexes + create-table inline indexes
|
|
309
|
+
// --------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
describe("diffSnapshots — indexes", () => {
|
|
312
|
+
test("13. drop table with index → [drop-index, drop-table] (drop-index first)", () => {
|
|
313
|
+
const idx: DdlIndex = { name: "idx_users_email", fields: ["email"], unique: true };
|
|
314
|
+
const before = snapshot([
|
|
315
|
+
resource("users", [userPK, field("email", { type: "email" })], [idx]),
|
|
316
|
+
]);
|
|
317
|
+
const after = snapshot([]);
|
|
318
|
+
const out = diffSnapshots(before, after);
|
|
319
|
+
expect(out.map((c) => c.kind)).toEqual(["drop-index", "drop-table"]);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("14. create table with declared index → single [create-table] (no separate add-index)", () => {
|
|
323
|
+
const idx: DdlIndex = { name: "idx_users_email", fields: ["email"], unique: true };
|
|
324
|
+
const after = snapshot([
|
|
325
|
+
resource("users", [userPK, field("email", { type: "email" })], [idx]),
|
|
326
|
+
]);
|
|
327
|
+
const out = diffSnapshots(null, after);
|
|
328
|
+
expect(out.map((c) => c.kind)).toEqual(["create-table"]);
|
|
329
|
+
// The inline index lives on the CREATE TABLE's resource payload —
|
|
330
|
+
// Agent A's emit is responsible for CREATE INDEX inside CREATE TABLE.
|
|
331
|
+
expect((out[0] as { resource: DdlResource }).resource.indexes).toHaveLength(1);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("20. adding indexed flag to an existing column via a new index → [add-index]", () => {
|
|
335
|
+
const idx: DdlIndex = { name: "idx_users_email", fields: ["email"], unique: false };
|
|
336
|
+
const before = snapshot([
|
|
337
|
+
resource("users", [userPK, field("email", { type: "email" })], []),
|
|
338
|
+
]);
|
|
339
|
+
const after = snapshot([
|
|
340
|
+
resource("users", [userPK, field("email", { type: "email" })], [idx]),
|
|
341
|
+
]);
|
|
342
|
+
const out = diffSnapshots(before, after);
|
|
343
|
+
expect(out).toHaveLength(1);
|
|
344
|
+
expect(out[0]).toEqual({ kind: "add-index", resourceName: "users", index: idx });
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test("changing an index's shape (e.g. unique flip) → drop-index + add-index", () => {
|
|
348
|
+
const before = snapshot([
|
|
349
|
+
resource("users", [userPK, field("email", { type: "email" })], [
|
|
350
|
+
{ name: "idx_users_email", fields: ["email"], unique: false },
|
|
351
|
+
]),
|
|
352
|
+
]);
|
|
353
|
+
const after = snapshot([
|
|
354
|
+
resource("users", [userPK, field("email", { type: "email" })], [
|
|
355
|
+
{ name: "idx_users_email", fields: ["email"], unique: true },
|
|
356
|
+
]),
|
|
357
|
+
]);
|
|
358
|
+
const out = diffSnapshots(before, after);
|
|
359
|
+
const kinds = out.map((c) => c.kind);
|
|
360
|
+
expect(kinds).toContain("drop-index");
|
|
361
|
+
expect(kinds).toContain("add-index");
|
|
362
|
+
// drop-index precedes add-index in the canonical order.
|
|
363
|
+
expect(kinds.indexOf("drop-index")).toBeLessThan(kinds.indexOf("add-index"));
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// --------------------------------------------------------------------
|
|
368
|
+
// TC-15..16 — full-order determinism
|
|
369
|
+
// --------------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
describe("diffSnapshots — determinism + cross-table order", () => {
|
|
372
|
+
test("15. identical inputs → byte-identical Change[]", () => {
|
|
373
|
+
const before = snapshot([
|
|
374
|
+
resource("users", [userPK, field("email", { type: "email" })]),
|
|
375
|
+
resource("posts", [userPK, field("title")]),
|
|
376
|
+
]);
|
|
377
|
+
const after = snapshot([
|
|
378
|
+
resource("users", [userPK, field("email", { type: "email" }), field("bio")]),
|
|
379
|
+
resource("posts", [userPK, field("title"), field("published", { type: "boolean" })]),
|
|
380
|
+
]);
|
|
381
|
+
const a = diffSnapshots(before, after);
|
|
382
|
+
const b = diffSnapshots(before, after);
|
|
383
|
+
expect(JSON.stringify(a)).toBe(JSON.stringify(b));
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("16. all 5 kinds in cross-table order: drop-index, drop-table, create-table, <per-table>, add-index", () => {
|
|
387
|
+
// Scenario:
|
|
388
|
+
// old: zebra_table (drops, has an index), kept_table (has an index to drop, a column to alter)
|
|
389
|
+
// new: kept_table (altered, with new index), apple_table (new)
|
|
390
|
+
const oldSnap = snapshot([
|
|
391
|
+
resource("zebra_table", [userPK, field("z")], [
|
|
392
|
+
{ name: "idx_zebra_z", fields: ["z"], unique: false },
|
|
393
|
+
]),
|
|
394
|
+
resource("kept_table", [
|
|
395
|
+
userPK,
|
|
396
|
+
field("active", {
|
|
397
|
+
type: "boolean",
|
|
398
|
+
default: { kind: "literal", value: false },
|
|
399
|
+
}),
|
|
400
|
+
], [{ name: "idx_kept_old", fields: ["active"], unique: false }]),
|
|
401
|
+
]);
|
|
402
|
+
const newSnap = snapshot([
|
|
403
|
+
resource("apple_table", [userPK]),
|
|
404
|
+
resource("kept_table", [
|
|
405
|
+
userPK,
|
|
406
|
+
field("active", {
|
|
407
|
+
type: "boolean",
|
|
408
|
+
default: { kind: "literal", value: true },
|
|
409
|
+
}),
|
|
410
|
+
], [{ name: "idx_kept_new", fields: ["active"], unique: false }]),
|
|
411
|
+
]);
|
|
412
|
+
const out = diffSnapshots(oldSnap, newSnap);
|
|
413
|
+
const kinds = out.map((c) => c.kind);
|
|
414
|
+
// Expected order (with specifics):
|
|
415
|
+
// drop-index idx_kept_old, drop-index idx_zebra_z,
|
|
416
|
+
// drop-table zebra_table,
|
|
417
|
+
// create-table apple_table,
|
|
418
|
+
// alter-column-default on kept_table.active,
|
|
419
|
+
// add-index idx_kept_new
|
|
420
|
+
expect(kinds).toEqual([
|
|
421
|
+
"drop-index",
|
|
422
|
+
"drop-index",
|
|
423
|
+
"drop-table",
|
|
424
|
+
"create-table",
|
|
425
|
+
"alter-column-default",
|
|
426
|
+
"add-index",
|
|
427
|
+
]);
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
// --------------------------------------------------------------------
|
|
432
|
+
// TC-17 — multiple alter kinds on the same column
|
|
433
|
+
// --------------------------------------------------------------------
|
|
434
|
+
|
|
435
|
+
describe("diffSnapshots — multi-kind alters", () => {
|
|
436
|
+
test("17. nullable + default both change → both Changes emit in sub-order (type, nullable, default)", () => {
|
|
437
|
+
const before = snapshot([
|
|
438
|
+
resource("users", [
|
|
439
|
+
userPK,
|
|
440
|
+
field("bio", {
|
|
441
|
+
type: "string",
|
|
442
|
+
nullable: false,
|
|
443
|
+
default: { kind: "literal", value: "" },
|
|
444
|
+
}),
|
|
445
|
+
]),
|
|
446
|
+
]);
|
|
447
|
+
const after = snapshot([
|
|
448
|
+
resource("users", [
|
|
449
|
+
userPK,
|
|
450
|
+
field("bio", {
|
|
451
|
+
type: "string",
|
|
452
|
+
nullable: true,
|
|
453
|
+
default: { kind: "null" },
|
|
454
|
+
}),
|
|
455
|
+
]),
|
|
456
|
+
]);
|
|
457
|
+
const out = diffSnapshots(before, after);
|
|
458
|
+
const kinds = out.map((c) => c.kind);
|
|
459
|
+
expect(kinds).toEqual(["alter-column-nullable", "alter-column-default"]);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test("type + nullable change → type precedes nullable", () => {
|
|
463
|
+
const before = snapshot([
|
|
464
|
+
resource("users", [userPK, field("x", { type: "string", nullable: false })]),
|
|
465
|
+
]);
|
|
466
|
+
const after = snapshot([
|
|
467
|
+
resource("users", [userPK, field("x", { type: "number", nullable: true })]),
|
|
468
|
+
]);
|
|
469
|
+
const out = diffSnapshots(before, after);
|
|
470
|
+
expect(out.map((c) => c.kind)).toEqual([
|
|
471
|
+
"alter-column-type",
|
|
472
|
+
"alter-column-nullable",
|
|
473
|
+
]);
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
// --------------------------------------------------------------------
|
|
478
|
+
// TC-18 — add-column on new PK field
|
|
479
|
+
// --------------------------------------------------------------------
|
|
480
|
+
|
|
481
|
+
describe("diffSnapshots — primary-key fields in add-column", () => {
|
|
482
|
+
test("18. new primary field → add-column carries primary:true on its field payload", () => {
|
|
483
|
+
const before = snapshot([resource("users", [field("tmp", { type: "string" })])]);
|
|
484
|
+
const after = snapshot([
|
|
485
|
+
resource("users", [
|
|
486
|
+
field("tmp", { type: "string" }),
|
|
487
|
+
field("id", { type: "uuid", primary: true }),
|
|
488
|
+
]),
|
|
489
|
+
]);
|
|
490
|
+
const out = diffSnapshots(before, after);
|
|
491
|
+
const add = out.find((c) => c.kind === "add-column")! as Extract<
|
|
492
|
+
Change,
|
|
493
|
+
{ kind: "add-column" }
|
|
494
|
+
>;
|
|
495
|
+
expect(add.field.primary).toBe(true);
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
// --------------------------------------------------------------------
|
|
500
|
+
// TC-19 — unique flip policy
|
|
501
|
+
// --------------------------------------------------------------------
|
|
502
|
+
|
|
503
|
+
describe("diffSnapshots — unique flip policy (v1)", () => {
|
|
504
|
+
test("19. flipping unique from false→true on a column emits alter-column-type stub (v1 workaround)", () => {
|
|
505
|
+
// Policy: v1 cannot express "add UNIQUE constraint" as a first-class Change.
|
|
506
|
+
// The correct future-facing response is an add-index with unique:true
|
|
507
|
+
// — but that index has no stable name until the user provides one. So
|
|
508
|
+
// v1 surfaces the flip as an alter-column-type stub so the user is
|
|
509
|
+
// forced to edit the generated SQL manually.
|
|
510
|
+
const before = snapshot([
|
|
511
|
+
resource("users", [userPK, field("email", { type: "email", unique: false })]),
|
|
512
|
+
]);
|
|
513
|
+
const after = snapshot([
|
|
514
|
+
resource("users", [userPK, field("email", { type: "email", unique: true })]),
|
|
515
|
+
]);
|
|
516
|
+
const out = diffSnapshots(before, after);
|
|
517
|
+
// We accept either a stub (today's v1 behavior: ignore unique flip since
|
|
518
|
+
// emit doesn't produce a UNIQUE constraint change) OR a dedicated Change.
|
|
519
|
+
// Document the v1 reality: the diff engine does not emit a dedicated
|
|
520
|
+
// Change for a unique flag flip. The user must add an explicit
|
|
521
|
+
// persistence.indexes entry with unique:true to get a migration.
|
|
522
|
+
const kinds = out.map((c) => c.kind);
|
|
523
|
+
// v1 choice: diff engine is silent; user must opt in explicitly via an
|
|
524
|
+
// indexes entry. This test documents that reality so it doesn't
|
|
525
|
+
// regress silently.
|
|
526
|
+
expect(kinds.includes("alter-column-type")).toBe(false);
|
|
527
|
+
expect(out).toEqual([]);
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
// --------------------------------------------------------------------
|
|
532
|
+
// TC-21 — convenience wrapper
|
|
533
|
+
// --------------------------------------------------------------------
|
|
534
|
+
|
|
535
|
+
describe("diffResources convenience wrapper", () => {
|
|
536
|
+
test("21. diffResources(parsed, null) computes next snapshot + create-table changes", () => {
|
|
537
|
+
const def: ResourceDefinition = {
|
|
538
|
+
name: "user",
|
|
539
|
+
fields: { id: { type: "uuid", required: true, primary: true } as any },
|
|
540
|
+
options: { persistence: { provider: "postgres" } } as any,
|
|
541
|
+
};
|
|
542
|
+
const parsed: ParsedResource = {
|
|
543
|
+
definition: def,
|
|
544
|
+
filePath: "/virtual/user.resource.ts",
|
|
545
|
+
fileName: "user",
|
|
546
|
+
resourceName: "user",
|
|
547
|
+
};
|
|
548
|
+
const { snapshot: snap, changes } = diffResources([parsed], null);
|
|
549
|
+
expect(snap.resources).toHaveLength(1);
|
|
550
|
+
expect(snap.resources[0].name).toBe("users");
|
|
551
|
+
expect(changes).toHaveLength(1);
|
|
552
|
+
expect(changes[0].kind).toBe("create-table");
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
test("diffResources roundtrips through an applied snapshot", () => {
|
|
556
|
+
const def: ResourceDefinition = {
|
|
557
|
+
name: "user",
|
|
558
|
+
fields: {
|
|
559
|
+
id: { type: "uuid", required: true, primary: true } as any,
|
|
560
|
+
email: { type: "email", required: true },
|
|
561
|
+
},
|
|
562
|
+
options: { persistence: { provider: "postgres" } } as any,
|
|
563
|
+
};
|
|
564
|
+
const parsed: ParsedResource = {
|
|
565
|
+
definition: def,
|
|
566
|
+
filePath: "/virtual/user.resource.ts",
|
|
567
|
+
fileName: "user",
|
|
568
|
+
resourceName: "user",
|
|
569
|
+
};
|
|
570
|
+
// First run: applied=null → create-table.
|
|
571
|
+
const r1 = diffResources([parsed], null);
|
|
572
|
+
expect(r1.changes[0].kind).toBe("create-table");
|
|
573
|
+
// Second run: applied=r1.snapshot → no change.
|
|
574
|
+
const r2 = diffResources([parsed], r1.snapshot);
|
|
575
|
+
expect(r2.changes).toEqual([]);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
test("diffResources propagates snapshot errors (e.g. missing PK)", () => {
|
|
579
|
+
const def: ResourceDefinition = {
|
|
580
|
+
name: "user",
|
|
581
|
+
fields: { name: { type: "string", required: true } },
|
|
582
|
+
options: { persistence: { provider: "postgres" } } as any,
|
|
583
|
+
};
|
|
584
|
+
const parsed: ParsedResource = {
|
|
585
|
+
definition: def,
|
|
586
|
+
filePath: "/virtual/user.resource.ts",
|
|
587
|
+
fileName: "user",
|
|
588
|
+
resourceName: "user",
|
|
589
|
+
};
|
|
590
|
+
expect(() => diffResources([parsed], null)).toThrow(/exactly one primary key/);
|
|
591
|
+
});
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
// --------------------------------------------------------------------
|
|
595
|
+
// Stress — many changes at once
|
|
596
|
+
// --------------------------------------------------------------------
|
|
597
|
+
|
|
598
|
+
describe("diffSnapshots — stress ordering", () => {
|
|
599
|
+
test("cross-table: drop-index always first, add-index always last", () => {
|
|
600
|
+
const before = snapshot([
|
|
601
|
+
resource(
|
|
602
|
+
"users",
|
|
603
|
+
[userPK, field("a"), field("b")],
|
|
604
|
+
[{ name: "idx_users_a", fields: ["a"], unique: false }]
|
|
605
|
+
),
|
|
606
|
+
resource("posts", [userPK, field("title")]),
|
|
607
|
+
]);
|
|
608
|
+
const after = snapshot([
|
|
609
|
+
resource(
|
|
610
|
+
"users",
|
|
611
|
+
[userPK, field("c"), field("b")],
|
|
612
|
+
[
|
|
613
|
+
{ name: "idx_users_b", fields: ["b"], unique: false },
|
|
614
|
+
]
|
|
615
|
+
),
|
|
616
|
+
resource(
|
|
617
|
+
"posts",
|
|
618
|
+
[userPK, field("title"), field("slug")],
|
|
619
|
+
[{ name: "idx_posts_slug", fields: ["slug"], unique: true }]
|
|
620
|
+
),
|
|
621
|
+
]);
|
|
622
|
+
const out = diffSnapshots(before, after);
|
|
623
|
+
const kinds = out.map((c) => c.kind);
|
|
624
|
+
|
|
625
|
+
const firstDropIdx = kinds.indexOf("drop-index");
|
|
626
|
+
const lastDropIdx = kinds.lastIndexOf("drop-index");
|
|
627
|
+
const firstAddIdx = kinds.indexOf("add-index");
|
|
628
|
+
const lastAddIdx = kinds.lastIndexOf("add-index");
|
|
629
|
+
|
|
630
|
+
expect(firstDropIdx).toBe(0);
|
|
631
|
+
// All drop-indexes precede any other kind.
|
|
632
|
+
expect(lastDropIdx).toBeLessThan(
|
|
633
|
+
kinds.findIndex((k, i) => i > lastDropIdx && k !== "drop-index")
|
|
634
|
+
);
|
|
635
|
+
// All add-indexes are at the end — last index equals array length - 1.
|
|
636
|
+
expect(lastAddIdx).toBe(kinds.length - 1);
|
|
637
|
+
expect(firstAddIdx).toBeGreaterThan(firstDropIdx);
|
|
638
|
+
});
|
|
639
|
+
});
|