@cosmicdrift/kumiko-framework 0.373.0 → 0.377.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/AGENTS.md +2 -2
- package/agents/examples/apex-landing.md +1 -1
- package/agents/examples/apex-surface-auth.md +3 -3
- package/agents/examples/compose-stacks.md +15 -8
- package/agents/examples/embedded-entity-form.md +1 -1
- package/agents/examples/notes-history-basic.md +1 -1
- package/agents/examples/tags-basic.md +1 -1
- package/agents/examples/user-profile.md +1 -1
- package/agents/examples/wizard-form.md +4 -4
- package/agents/widgets.md +4 -3
- package/dist/api/index.d.ts +1 -1
- package/dist/api/index.js +1 -1
- package/dist/api/routes.js +2 -2
- package/dist/api/server.js +2 -1
- package/dist/config/index.d.ts +4 -0
- package/dist/db/index.d.ts +2 -2
- package/dist/db/index.js +1 -1
- package/dist/db/migrate-runner.js +12 -4
- package/dist/db/money.js +1 -1
- package/dist/db/query.js +14 -14
- package/dist/db/replay-migration-sql.d.ts +1 -0
- package/dist/db/replay-migration-sql.js +115 -11
- package/dist/db/tenant-db.d.ts +7 -0
- package/dist/db/tenant-db.js +17 -5
- package/dist/engine/boot-validator/projection-list-screens.js +28 -37
- package/dist/engine/boot-validator/query-refs.js +26 -8
- package/dist/engine/boot-validator/screens.d.ts +1 -1
- package/dist/engine/boot-validator/screens.js +334 -42
- package/dist/engine/boot-validator/url-prefill-fields.js +3 -0
- package/dist/engine/build-app-schema.js +7 -3
- package/dist/engine/entity-list-searchability.d.ts +5 -0
- package/dist/engine/entity-list-searchability.js +35 -0
- package/dist/engine/feature-ui-extensions.js +5 -2
- package/dist/engine/index.d.ts +3 -1
- package/dist/engine/index.js +2 -0
- package/dist/engine/paged-list.d.ts +29 -0
- package/dist/engine/paged-list.js +56 -0
- package/dist/engine/parse-ref-target.js +1 -1
- package/dist/engine/project-app-schema-for-roles.js +37 -6
- package/dist/engine/provider-plugin-context.d.ts +3 -0
- package/dist/engine/provider-plugin-context.js +28 -0
- package/dist/engine/schema-builder.js +7 -0
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/env/index.js +1 -1
- package/dist/errors/zod-bridge.js +1 -1
- package/dist/files/file-ref-entity.d.ts +6 -6
- package/dist/files/file-ref-table.d.ts +6 -6
- package/dist/files/index.d.ts +1 -1
- package/dist/files/index.js +1 -1
- package/dist/files/provider-resolver.d.ts +17 -17
- package/dist/files/provider-resolver.js +1 -1
- package/dist/http/egress.d.ts +14 -0
- package/dist/http/egress.js +144 -0
- package/dist/http/index.d.ts +3 -2
- package/dist/http/index.js +2 -1
- package/dist/http/policy.d.ts +30 -0
- package/dist/http/policy.js +299 -0
- package/dist/i18n/required-surface-keys.js +52 -2
- package/dist/jobs/job-runner.js +4 -2
- package/dist/pipeline/config-accessor-factory.d.ts +3 -0
- package/dist/pipeline/config-accessor-factory.js +3 -0
- package/dist/pipeline/dispatch-shared.js +7 -5
- package/dist/pipeline/index.d.ts +1 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/ui-types/index.d.ts +1 -1
- package/dist/upgrade-changelog.d.ts +25 -0
- package/dist/upgrade-changelog.js +48 -0
- package/dist/upgrade-cli.js +78 -13
- package/package.json +3 -5
- package/presets/biome-app.json +8 -0
- package/src/changes.json +132 -0
- package/src/scripts/codemod/README.md +25 -0
- package/src/scripts/codemod/codemod-support.ts +64 -6
- package/src/scripts/codemod/package-merge.ts +260 -0
- package/src/scripts/codemod/s3-imports.ts +4 -42
- package/src/scripts/codemod/testing-injection.ts +135 -0
package/AGENTS.md
CHANGED
|
@@ -10,7 +10,7 @@ not exist in your version:
|
|
|
10
10
|
| File | What it holds |
|
|
11
11
|
| --- | --- |
|
|
12
12
|
| `agents/features.md` | Every bundled feature: name, purpose, config keys, dependencies |
|
|
13
|
-
| `agents/widgets.md` | Every value export of `@cosmicdrift/kumiko-renderer
|
|
13
|
+
| `agents/widgets.md` | Every value export of `@cosmicdrift/kumiko-renderer/web` with its short description |
|
|
14
14
|
| `agents/examples.md` | Index of the worked examples, one per concept |
|
|
15
15
|
| `agents/examples/<name>.md` | One tested sample app: README plus every source and test file |
|
|
16
16
|
|
|
@@ -238,7 +238,7 @@ produces code that compiles and then fails in production.
|
|
|
238
238
|
- Never write your own JSX for a list, form, detail page or dashboard.
|
|
239
239
|
Declare a screen. Use `type: "custom"` only when no screen type fits.
|
|
240
240
|
- Inside a custom screen, never build your own card, table, badge, button or
|
|
241
|
-
chart. Use the widgets from `@cosmicdrift/kumiko-renderer
|
|
241
|
+
chart. Use the widgets from `@cosmicdrift/kumiko-renderer/web`
|
|
242
242
|
(`agents/widgets.md`) and the primitives from `usePrimitives()`.
|
|
243
243
|
- Never fetch data with `useEffect` plus `fetch`. Use `useQuery`
|
|
244
244
|
(`live: true` for server-sent updates) and `useMutation`.
|
|
@@ -238,7 +238,7 @@ import {
|
|
|
238
238
|
type ApexPage,
|
|
239
239
|
type ApexPricingTier,
|
|
240
240
|
renderApexPage,
|
|
241
|
-
} from "@cosmicdrift/kumiko-headless/apex";
|
|
241
|
+
} from "@cosmicdrift/kumiko-renderer/headless/apex";
|
|
242
242
|
import { HERO_SCREENSHOT } from "./constants";
|
|
243
243
|
|
|
244
244
|
// --- Inputs: shapes the surrounding features hand you -----------------------
|
|
@@ -42,7 +42,7 @@ import {
|
|
|
42
42
|
import {
|
|
43
43
|
RequestAccountDeletionScreen, ConfirmAccountDeletionScreen,
|
|
44
44
|
} from "@cosmicdrift/kumiko-bundled-features/user-data-rights/web";
|
|
45
|
-
import { createPublicSurface } from "@cosmicdrift/kumiko-renderer
|
|
45
|
+
import { createPublicSurface } from "@cosmicdrift/kumiko-renderer/web";
|
|
46
46
|
|
|
47
47
|
// createLoginRoute, not a raw LoginScreen render — it owns the challenge-
|
|
48
48
|
// swap for a second factor, so mounting auth-mfa later doesn't need this
|
|
@@ -252,7 +252,7 @@ describe("apex-surface-auth recipe", () => {
|
|
|
252
252
|
// 2. Client (Kommentar unten + README): wie createPublicSurface + AuthShell
|
|
253
253
|
// die Screens in der Apex-Chrome mounten.
|
|
254
254
|
//
|
|
255
|
-
// CLIENT-WIRING (apex.tsx der App — renderer
|
|
255
|
+
// CLIENT-WIRING (apex.tsx der App — renderer/web, hier nur als Referenz, da
|
|
256
256
|
// Recipes keine Browser-Deps ziehen):
|
|
257
257
|
//
|
|
258
258
|
// import {
|
|
@@ -263,7 +263,7 @@ describe("apex-surface-auth recipe", () => {
|
|
|
263
263
|
// RequestAccountDeletionScreen, ConfirmAccountDeletionScreen,
|
|
264
264
|
// defaultTranslations as deletionI18n,
|
|
265
265
|
// } from "@cosmicdrift/kumiko-bundled-features/user-data-rights/web";
|
|
266
|
-
// import { createPublicSurface } from "@cosmicdrift/kumiko-renderer
|
|
266
|
+
// import { createPublicSurface } from "@cosmicdrift/kumiko-renderer/web";
|
|
267
267
|
//
|
|
268
268
|
// // AuthShell: die Auth-Card rendert in der Marketing-Chrome statt
|
|
269
269
|
// // Fullscreen. Default (ohne Provider) bleibt der Fullscreen-Wrapper.
|
|
@@ -44,22 +44,25 @@ the option.
|
|
|
44
44
|
| `composeGdprStack` | `kmsMode` | `kms` or `allowPlaintextPii` |
|
|
45
45
|
| `composeGdprStack({ selfService })`, `composeUserDataRightsStack` | `mail` | a mounted `channel-email` |
|
|
46
46
|
| `composeSecurityStack` | `kmsMode`, `rateLimit` not `false` | `kms` or `allowPlaintextPii` |
|
|
47
|
-
| `composeSaasStack({ billing: "stripe" })` | `billing` | a mounted subscription provider |
|
|
47
|
+
| `composeSaasStack({ billing: "stripe" })` | `billing` (warning only) | a mounted subscription provider |
|
|
48
48
|
|
|
49
49
|
A missing option fails the boot with:
|
|
50
50
|
|
|
51
51
|
```
|
|
52
52
|
[runProdApp] stacks in `features` need boot options that are not set:
|
|
53
|
-
-
|
|
53
|
+
- composeGdprStack needs the boot option `kmsMode`: set kmsMode: "required" (or { plaintextReason } for a demo)
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
+
A stripe `composeSaasStack` without `billing` boots and logs the same line as a warning, so an
|
|
57
|
+
app can go live before its prices exist.
|
|
58
|
+
|
|
56
59
|
## Flow
|
|
57
60
|
|
|
58
61
|
1. `src/app.ts` lists the stacks and the boot options they need.
|
|
59
62
|
2. The test boots with `KUMIKO_DRY_RUN_ENV=boot`, which runs the whole
|
|
60
63
|
validation and stops before any DB or Redis connection: with the options
|
|
61
|
-
the boot passes, without `
|
|
62
|
-
|
|
64
|
+
the boot passes, without `kmsMode` it fails and names the stacks, and
|
|
65
|
+
without `billing` it warns.
|
|
63
66
|
|
|
64
67
|
## Tests
|
|
65
68
|
|
|
@@ -122,11 +125,15 @@ describe("compose stacks recipe", () => {
|
|
|
122
125
|
expect(lines.some((line: string) => line.includes("boot validation OK"))).toBe(true);
|
|
123
126
|
});
|
|
124
127
|
|
|
125
|
-
test("a missing billing option
|
|
128
|
+
test("a missing billing option only warns and names composeSaasStack", async () => {
|
|
126
129
|
const { billing: _billing, ...withoutBilling } = bootOptions();
|
|
127
|
-
await
|
|
128
|
-
|
|
129
|
-
|
|
130
|
+
await bootValidate(withoutBilling);
|
|
131
|
+
const warnings = warnSpy.mock.calls.map((call: unknown[]) => String(call[0]));
|
|
132
|
+
expect(
|
|
133
|
+
warnings.some((line: string) =>
|
|
134
|
+
line.includes("composeSaasStack needs the boot option `billing`"),
|
|
135
|
+
),
|
|
136
|
+
).toBe(true);
|
|
130
137
|
});
|
|
131
138
|
|
|
132
139
|
test("a missing KMS decision names every stack that needs it", async () => {
|
|
@@ -471,7 +471,7 @@ export const prospectAcceptScreen: EntityEditScreenDefinition = {
|
|
|
471
471
|
```tsx
|
|
472
472
|
import type { RenderEditControls } from "@cosmicdrift/kumiko-renderer";
|
|
473
473
|
import { RenderEdit, useDispatcher, usePrimitives } from "@cosmicdrift/kumiko-renderer";
|
|
474
|
-
import { Drawer } from "@cosmicdrift/kumiko-renderer
|
|
474
|
+
import { Drawer } from "@cosmicdrift/kumiko-renderer/web";
|
|
475
475
|
import { type ReactNode, useMemo, useRef, useState } from "react";
|
|
476
476
|
import { z } from "zod";
|
|
477
477
|
import { prospectEntity } from "../entities/prospect";
|
|
@@ -55,7 +55,7 @@ handler as above. Register `notesHistoryClient()` once (for its component +
|
|
|
55
55
|
i18n), then mount it either way:
|
|
56
56
|
|
|
57
57
|
```tsx illustration
|
|
58
|
-
import { createKumikoApp } from "@cosmicdrift/kumiko-renderer
|
|
58
|
+
import { createKumikoApp } from "@cosmicdrift/kumiko-renderer/web";
|
|
59
59
|
import { notesHistoryClient, NotesSection, NOTES_SECTION_EXTENSION_NAME } from "@cosmicdrift/kumiko-bundled-features/notes-history/web";
|
|
60
60
|
|
|
61
61
|
// once, at app boot — required even for standalone use (registers i18n):
|
|
@@ -56,7 +56,7 @@ as above. Register `tagsClient()` once (for its component + i18n), then mount it
|
|
|
56
56
|
either way:
|
|
57
57
|
|
|
58
58
|
```tsx illustration
|
|
59
|
-
import { createKumikoApp } from "@cosmicdrift/kumiko-renderer
|
|
59
|
+
import { createKumikoApp } from "@cosmicdrift/kumiko-renderer/web";
|
|
60
60
|
import { tagsClient, TagSection, TAGS_SECTION_EXTENSION_NAME } from "@cosmicdrift/kumiko-bundled-features/tags/web";
|
|
61
61
|
|
|
62
62
|
// once, at app boot — required even for standalone use (registers i18n):
|
|
@@ -54,7 +54,7 @@ itself needs no client registration):
|
|
|
54
54
|
```tsx illustration
|
|
55
55
|
import { userProfileClient } from "@cosmicdrift/kumiko-bundled-features/user-profile/web";
|
|
56
56
|
import { emailPasswordClient } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/web";
|
|
57
|
-
import { createKumikoApp } from "@cosmicdrift/kumiko-renderer
|
|
57
|
+
import { createKumikoApp } from "@cosmicdrift/kumiko-renderer/web";
|
|
58
58
|
|
|
59
59
|
createKumikoApp({
|
|
60
60
|
clientFeatures: [emailPasswordClient(), userProfileClient()],
|
|
@@ -338,11 +338,11 @@ export const listingsFeature = defineFeature("listings", (r) => {
|
|
|
338
338
|
// A `kind: "extension"` section only resolves through
|
|
339
339
|
// clientFeatures.extensionSectionComponents (createKumikoApp → ExtensionSectionsProvider)
|
|
340
340
|
// — never imported directly from feature.ts, which keeps the server file
|
|
341
|
-
// free of the renderer
|
|
341
|
+
// free of the renderer/web graph. Without registering this export, an app
|
|
342
342
|
// mounting this recipe gets RenderEdit's fallback placeholder instead of
|
|
343
343
|
// the real review step.
|
|
344
344
|
|
|
345
|
-
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer
|
|
345
|
+
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer/web";
|
|
346
346
|
import { ListingReviewSection } from "./listing-review-section";
|
|
347
347
|
|
|
348
348
|
export const listingsClient: ClientFeatureDefinition = {
|
|
@@ -355,13 +355,13 @@ export const listingsClient: ClientFeatureDefinition = {
|
|
|
355
355
|
|
|
356
356
|
```tsx
|
|
357
357
|
import type { ExtensionSectionProps } from "@cosmicdrift/kumiko-renderer";
|
|
358
|
-
import { DetailList } from "@cosmicdrift/kumiko-renderer
|
|
358
|
+
import { DetailList } from "@cosmicdrift/kumiko-renderer/web";
|
|
359
359
|
import type { ReactNode } from "react";
|
|
360
360
|
|
|
361
361
|
// Registered under the `__component` name the feature's review section
|
|
362
362
|
// points at (see feature.ts) — never imported by feature.ts itself, the
|
|
363
363
|
// string name is the only link so the server file stays free of the
|
|
364
|
-
// renderer
|
|
364
|
+
// renderer/web graph.
|
|
365
365
|
export function ListingReviewSection({ values }: ExtensionSectionProps): ReactNode {
|
|
366
366
|
return (
|
|
367
367
|
<DetailList
|
package/agents/widgets.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
<!-- Generated by `bun run gen:agent-docs` from packages/renderer
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from packages/renderer/src/web/index.ts. Do not edit. -->
|
|
2
2
|
|
|
3
3
|
# Widgets
|
|
4
4
|
|
|
5
|
-
Value exports of `@cosmicdrift/kumiko-renderer
|
|
5
|
+
Value exports of `@cosmicdrift/kumiko-renderer/web`.
|
|
6
6
|
|
|
7
7
|
## Components
|
|
8
8
|
|
|
@@ -118,6 +118,7 @@ Value exports of `@cosmicdrift/kumiko-renderer-web`.
|
|
|
118
118
|
| `useDraft` | Formular-State für Rechner-Screens: ein Draft-Objekt + `patch` für Teil-Updates + `field(name)` das die `{ id, name, value, onChange }`-Props fertig für die Feld-Widgets (NumberField/MoneyField/PercentField) liefert. |
|
|
119
119
|
| `useForm` | |
|
|
120
120
|
| `useIsNarrowViewport` | |
|
|
121
|
+
| `useIsViewportBelow` | |
|
|
121
122
|
| `useLiveEvents` | Hook für useQuery-like Consumer. |
|
|
122
123
|
| `useLocale` | Liefert den aktuellen LocaleResolver und abonniert automatisch Locale-Änderungen — der aufrufende Component re-rendert sobald die Sprache gewechselt wird. |
|
|
123
124
|
| `useNav` | |
|
|
@@ -173,7 +174,7 @@ Value exports of `@cosmicdrift/kumiko-renderer-web`.
|
|
|
173
174
|
| --- | --- |
|
|
174
175
|
| `BROWSER_LOCALE_STORAGE_KEY` | |
|
|
175
176
|
| `STATUS_TONE_TEXT` | |
|
|
176
|
-
| `cssVarTokens` | Tokens
|
|
177
|
+
| `cssVarTokens` | Tokens with var-string values. |
|
|
177
178
|
| `defaultPrimitives` | |
|
|
178
179
|
| `defaultTokens` | Default-Tokens — identisch zu `cssVarTokens` (var-string-refs). |
|
|
179
180
|
| `lightTokens` | |
|
package/dist/api/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export { deleteTenantCookie, setTenantCookie } from "./anonymous-cookie.ts";
|
|
|
3
3
|
export { collectAnonymousLiveEntities } from "./anonymous-live-entities.ts";
|
|
4
4
|
export { LOCALE_HEADER_NAME, NO_ROUTE_MATCH_HEADER_NAME } from "./api-constants.ts";
|
|
5
5
|
export type { AnonymousAccessConfig, AnonymousAccessResolved, AuthMiddlewareOptions, AuthSessionChecker, AuthSessionCheckResult, AuthSessionStatus, TenantExists, TenantLifecycleStatusResolver, TenantResolver, TokenVerifier, } from "./auth-middleware.ts";
|
|
6
|
-
export { authMiddleware, getUser, PAT_TOKEN_PREFIX, sessionCheckStatus, } from "./auth-middleware.ts";
|
|
6
|
+
export { authMiddleware, CSRF_HEADER_NAME, getUser, PAT_TOKEN_PREFIX, sessionCheckStatus, } from "./auth-middleware.ts";
|
|
7
7
|
export type { AuthRoutesConfig, LoginRateLimiter, SessionChecker, SessionCreator, SessionMassRevoker, SessionMetadata, SessionRevoker, } from "./auth-routes.ts";
|
|
8
8
|
export { createAuthRoutes, createInMemoryLoginRateLimiter, createRedisLoginRateLimiter, } from "./auth-routes.ts";
|
|
9
9
|
export type { ClientIpHeaderSource, ClientIpResolver } from "./client-ip.ts";
|
package/dist/api/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { deleteTenantCookie, setTenantCookie } from "./anonymous-cookie.js";
|
|
2
2
|
export { collectAnonymousLiveEntities } from "./anonymous-live-entities.js";
|
|
3
3
|
export { LOCALE_HEADER_NAME, NO_ROUTE_MATCH_HEADER_NAME } from "./api-constants.js";
|
|
4
|
-
export { authMiddleware, getUser, PAT_TOKEN_PREFIX, sessionCheckStatus, } from "./auth-middleware.js";
|
|
4
|
+
export { authMiddleware, CSRF_HEADER_NAME, getUser, PAT_TOKEN_PREFIX, sessionCheckStatus, } from "./auth-middleware.js";
|
|
5
5
|
export { createAuthRoutes, createInMemoryLoginRateLimiter, createRedisLoginRateLimiter, } from "./auth-routes.js";
|
|
6
6
|
export { assertValidTrustedProxyHops, clientIpSourceFromHonoContext, createClientIpResolver, extractSocketAddress, parseTrustedProxyHopsEnv, TRUSTED_PROXY_HOPS_ENV, UNKNOWN_CLIENT_IP, } from "./client-ip.js";
|
|
7
7
|
export { ExtraRouteEntries, ExtraRouteRejection, signatureRoute } from "./extra-route.js";
|
package/dist/api/routes.js
CHANGED
|
@@ -11,8 +11,8 @@ import { patAllows } from "./pat-scope.js";
|
|
|
11
11
|
import { requestContext } from "./request-context.js";
|
|
12
12
|
import { SSE_HEARTBEAT_INTERVAL_MS } from "./sse-route.js";
|
|
13
13
|
// SSE frame event names for POST /api/stream. Parallel definition lives in
|
|
14
|
-
// @cosmicdrift/kumiko-headless (
|
|
15
|
-
// cannot depend on
|
|
14
|
+
// @cosmicdrift/kumiko-renderer/headless (the live dispatcher imports that one) — framework
|
|
15
|
+
// cannot depend on the renderer for four string literals.
|
|
16
16
|
export const StreamFrame = {
|
|
17
17
|
chunk: "chunk",
|
|
18
18
|
ping: "ping",
|
package/dist/api/server.js
CHANGED
|
@@ -18,6 +18,7 @@ import { createRecordWriteCheck } from "../files/record-write-check.js";
|
|
|
18
18
|
import { createFallbackLogger } from "../logging/index.js";
|
|
19
19
|
import { createNoopProvider, registerStandardMetrics, wrapRedisClient, } from "../observability/index.js";
|
|
20
20
|
import { resolveTenantLifecyclePlugin } from "../pipeline/active-membership.js";
|
|
21
|
+
import { configAccessorFactoryOf } from "../pipeline/config-accessor-factory.js";
|
|
21
22
|
import { createDispatcher } from "../pipeline/dispatcher.js";
|
|
22
23
|
import { SHARED_INSTANCE_SENTINEL } from "../pipeline/event-consumer-state.js";
|
|
23
24
|
import { createEventDispatcher } from "../pipeline/event-dispatcher.js";
|
|
@@ -63,7 +64,7 @@ export function withFileProviderResolver(registry, context, cacheSync) {
|
|
|
63
64
|
...context,
|
|
64
65
|
_fileProviderResolver: makeFileProviderResolver({
|
|
65
66
|
registry,
|
|
66
|
-
_configAccessorFactory: context
|
|
67
|
+
_configAccessorFactory: configAccessorFactoryOf(context),
|
|
67
68
|
secrets: context.secrets,
|
|
68
69
|
db: context.db,
|
|
69
70
|
...(cacheSync ? { cacheSync } : {}),
|
package/dist/config/index.d.ts
CHANGED
|
@@ -27,6 +27,10 @@ export type AppConfig = {
|
|
|
27
27
|
readonly features: readonly FeatureDefinition[];
|
|
28
28
|
/** Features mounted only in production on top of `features`. */
|
|
29
29
|
readonly prodFeatures?: readonly FeatureDefinition[];
|
|
30
|
+
/** Whether the app boots with the bundled auth features (config/user/tenant/auth-email-password)
|
|
31
|
+
* that composeFeatures adds; the schema tooling needs their tables. Unset: derived from the
|
|
32
|
+
* feature list. */
|
|
33
|
+
readonly includeBundled?: boolean;
|
|
30
34
|
readonly client: AppClientConfig;
|
|
31
35
|
readonly ports: AppPorts;
|
|
32
36
|
/** Passed to validateBoot by `kumiko check` and, via the app's bin files, by the runners. */
|
package/dist/db/index.d.ts
CHANGED
|
@@ -35,5 +35,5 @@ export { diffReplayAgainstSnapshot, type ReplayedSchema, type ReplayedTable, typ
|
|
|
35
35
|
export { findCommentedDropTables, isRetiredFrameworkTable, type RetiredFrameworkTable, retiredFrameworkTables, } from "./retired-framework-tables.ts";
|
|
36
36
|
export { tableExists } from "./schema-inspection.ts";
|
|
37
37
|
export { buildBaseColumns, buildEntityTable, declareGlobalTenancy, type EntityRow, type EntityTable, physicalColumnName, toSnakeCase, toTableName, } from "./table-builder.ts";
|
|
38
|
-
export type { CreateTenantDbOptions, TenantDb, TenantDbGrants, TenantDbMode, UncheckedSystemDb, } from "./tenant-db.ts";
|
|
39
|
-
export { castTenantRows, createSystemDbView, createTenantDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, scopeTenantDbToTenant, } from "./tenant-db.ts";
|
|
38
|
+
export type { CreateTenantDbOptions, OutsideTransactionContext, TenantDb, TenantDbGrants, TenantDbMode, UncheckedSystemDb, } from "./tenant-db.ts";
|
|
39
|
+
export { castTenantRows, createSystemDbView, createTenantDb, hasOutsideTransactionDb, requireOutsideTransactionDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, scopeTenantDbToTenant, } from "./tenant-db.ts";
|
package/dist/db/index.js
CHANGED
|
@@ -28,4 +28,4 @@ export { diffReplayAgainstSnapshot, replayMigrationsDir, } from "./replay-migrat
|
|
|
28
28
|
export { findCommentedDropTables, isRetiredFrameworkTable, retiredFrameworkTables, } from "./retired-framework-tables.js";
|
|
29
29
|
export { tableExists } from "./schema-inspection.js";
|
|
30
30
|
export { buildBaseColumns, buildEntityTable, declareGlobalTenancy, physicalColumnName, toSnakeCase, toTableName, } from "./table-builder.js";
|
|
31
|
-
export { castTenantRows, createSystemDbView, createTenantDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, scopeTenantDbToTenant, } from "./tenant-db.js";
|
|
31
|
+
export { castTenantRows, createSystemDbView, createTenantDb, hasOutsideTransactionDb, requireOutsideTransactionDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, scopeTenantDbToTenant, } from "./tenant-db.js";
|
|
@@ -111,8 +111,18 @@ export function splitSqlStatements(sqlText) {
|
|
|
111
111
|
current += ch;
|
|
112
112
|
continue;
|
|
113
113
|
}
|
|
114
|
-
if (ch === "$"
|
|
115
|
-
|
|
114
|
+
if (ch === "$") {
|
|
115
|
+
const opening = /^\$([A-Za-z_]\w*)?\$/.exec(sqlText.slice(i))?.[0];
|
|
116
|
+
if (opening !== undefined) {
|
|
117
|
+
// Body is copied verbatim: `;` and quotes inside a DO/function body belong to it.
|
|
118
|
+
const closeAt = sqlText.indexOf(opening, i + opening.length);
|
|
119
|
+
if (closeAt === -1) {
|
|
120
|
+
throw new Error(`splitSqlStatements: unterminated dollar-quoted body ${opening} — migration SQL is malformed, refusing to split`);
|
|
121
|
+
}
|
|
122
|
+
current += sqlText.slice(i, closeAt + opening.length);
|
|
123
|
+
i = closeAt + opening.length - 1;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
116
126
|
}
|
|
117
127
|
if (ch === ";") {
|
|
118
128
|
statements.push(current);
|
|
@@ -122,8 +132,6 @@ export function splitSqlStatements(sqlText) {
|
|
|
122
132
|
current += ch;
|
|
123
133
|
}
|
|
124
134
|
if (state === "blockComment" || state === "singleQuote" || state === "doubleQuote") {
|
|
125
|
-
// Does not track dollar-quoted (`$$...$$`) bodies — none of this repo's
|
|
126
|
-
// migrations use them; add that state if one ever does.
|
|
127
135
|
throw new Error(`splitSqlStatements: unterminated ${state} — migration SQL is malformed, refusing to split`);
|
|
128
136
|
}
|
|
129
137
|
statements.push(current);
|
package/dist/db/money.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// round-tripping the float amount through /100·*100.
|
|
11
11
|
// `amountMinor` stays as a @deprecated alias of the same
|
|
12
12
|
// value until #1976/4 and #1976/5 migrate their remaining
|
|
13
|
-
// consumers (renderer
|
|
13
|
+
// consumers (renderer/web/primitives/index.tsx,
|
|
14
14
|
// renderer/components/render-field.tsx) off the old name.
|
|
15
15
|
//
|
|
16
16
|
// table-builder.ts's moneyAmount column has always documented BIGINT as
|
package/dist/db/query.js
CHANGED
|
@@ -44,7 +44,7 @@ function snakeToCamel(key) {
|
|
|
44
44
|
// the only collision-safe path.
|
|
45
45
|
export { asEntityTableMeta, requireEntityTableMeta };
|
|
46
46
|
export function hasBegin(client) {
|
|
47
|
-
//
|
|
47
|
+
// Tx/savepoint handles can keep a begin() that throws; only savepoint() tells them apart from a pool.
|
|
48
48
|
return typeof client.begin === "function" && typeof client.savepoint !== "function";
|
|
49
49
|
}
|
|
50
50
|
function isRawClient(value) {
|
|
@@ -77,7 +77,7 @@ export function asRawClient(db) {
|
|
|
77
77
|
export function runnerCanBeginTransaction(db) {
|
|
78
78
|
return hasBegin(asRawClient(db));
|
|
79
79
|
}
|
|
80
|
-
// postgres.js
|
|
80
|
+
// postgres.js rejects the WHOLE begin() when any statement inside
|
|
81
81
|
// it errored — even if the JS error was caught (the driver tracks per-block
|
|
82
82
|
// query failures). Error confinement therefore must go through the driver's
|
|
83
83
|
// own savepoint(), not manual SAVEPOINT statements. The callback receives
|
|
@@ -85,7 +85,7 @@ export function runnerCanBeginTransaction(db) {
|
|
|
85
85
|
export async function runInSavepoint(tx, fn) {
|
|
86
86
|
const raw = asRawClient(tx);
|
|
87
87
|
if (typeof raw.savepoint !== "function") {
|
|
88
|
-
throw new Error("runInSavepoint: transaction handle has no savepoint() — pass the driver tx (postgres.js TransactionSql
|
|
88
|
+
throw new Error("runInSavepoint: transaction handle has no savepoint() — pass the driver tx (postgres.js TransactionSql).");
|
|
89
89
|
}
|
|
90
90
|
return raw.savepoint(fn);
|
|
91
91
|
}
|
|
@@ -180,7 +180,7 @@ function isWhereOperator(v) {
|
|
|
180
180
|
export function extractTableInfo(table) {
|
|
181
181
|
const meta = asEntityTableMeta(table);
|
|
182
182
|
if (!meta) {
|
|
183
|
-
throw new Error("
|
|
183
|
+
throw new Error("extractTableInfo: table is not a kumiko EntityTableMeta — " +
|
|
184
184
|
"build it via buildEntityTable / deriveEntityTableMeta / table().");
|
|
185
185
|
}
|
|
186
186
|
const colByField = new Map();
|
|
@@ -282,7 +282,7 @@ function isTemporalPlainDateTimeLike(v) {
|
|
|
282
282
|
"toPlainDate" in v &&
|
|
283
283
|
typeof v.toPlainDate === "function");
|
|
284
284
|
}
|
|
285
|
-
// Verified empirically
|
|
285
|
+
// Verified empirically (kumiko-framework#1924): a `date`
|
|
286
286
|
// column comes back as a native JS Date, but ALWAYS anchored at UTC
|
|
287
287
|
// midnight regardless of process TZ — epochMilliseconds is stable across
|
|
288
288
|
// zones, only the local wall-clock read of that Date would drift the day
|
|
@@ -296,7 +296,7 @@ function isTemporalPlainDateTimeLike(v) {
|
|
|
296
296
|
// the day, so say so once per column instead of returning the wrong day quietly.
|
|
297
297
|
const warnedNonMidnightDateColumns = new Set();
|
|
298
298
|
const MS_PER_DAY = 86_400_000;
|
|
299
|
-
const dateColumnDriftLog = createFallbackLogger("
|
|
299
|
+
const dateColumnDriftLog = createFallbackLogger("postgres-js");
|
|
300
300
|
function warnIfNotDateColumn(epochMs, column) {
|
|
301
301
|
// skip: midnight value is a correct date column, and an already-warned column is deduped
|
|
302
302
|
if (epochMs % MS_PER_DAY === 0 || warnedNonMidnightDateColumns.has(column))
|
|
@@ -384,7 +384,7 @@ function coerceDriverRow(row, info) {
|
|
|
384
384
|
}
|
|
385
385
|
else if ((pgType === "integer" || pgType === "int4" || pgType === "smallint" || pgType === "int2") &&
|
|
386
386
|
(typeof value === "bigint" || typeof value === "string")) {
|
|
387
|
-
//
|
|
387
|
+
// Drivers may return int4 as bigint or numeric string.
|
|
388
388
|
// Drizzle coerced to number for numberField columns — match that.
|
|
389
389
|
const n = Number(value);
|
|
390
390
|
if (!Number.isNaN(n))
|
|
@@ -469,8 +469,8 @@ const INTEGER_PG_TYPES = new Set([
|
|
|
469
469
|
"serial",
|
|
470
470
|
"bigserial",
|
|
471
471
|
]);
|
|
472
|
-
//
|
|
473
|
-
//
|
|
472
|
+
// Postgres rounds sub-millisecond params into timestamptz(3); truncating to ms here keeps the
|
|
473
|
+
// stored instant identical across drivers and equal to what postgres.js sends via Date.
|
|
474
474
|
export function instantToTimestamptzParam(instant) {
|
|
475
475
|
return Temporal.Instant.fromEpochMilliseconds(instant.epochMilliseconds).toString();
|
|
476
476
|
}
|
|
@@ -478,7 +478,7 @@ function prepareDriverNeutralScalar(value, pgType) {
|
|
|
478
478
|
if ((pgType === "timestamptz" || pgType === "timestamptz(3)") && isTemporalInstant(value)) {
|
|
479
479
|
return { kind: "param", sql: "", bound: instantToTimestamptzParam(value) };
|
|
480
480
|
}
|
|
481
|
-
//
|
|
481
|
+
// postgres.js sends text, so Postgres rejects 3.5 for an integer column instead of rounding it.
|
|
482
482
|
if (typeof value === "number" && pgType !== undefined && INTEGER_PG_TYPES.has(pgType)) {
|
|
483
483
|
return { kind: "param", sql: "", bound: String(value) };
|
|
484
484
|
}
|
|
@@ -489,7 +489,7 @@ function prepareValue(value, pgType) {
|
|
|
489
489
|
return { kind: "literal", literal: value.text };
|
|
490
490
|
}
|
|
491
491
|
// A PII-encrypted jsonb field is a JSON string scalar; a raw string bound to
|
|
492
|
-
// ::jsonb would be parsed as JSON text (or double-encoded
|
|
492
|
+
// ::jsonb would be parsed as JSON text (or double-encoded).
|
|
493
493
|
if (pgType === "jsonb" && isPiiCiphertext(value)) {
|
|
494
494
|
return { kind: "param", sql: "::text::jsonb", bound: JSON.stringify(value) };
|
|
495
495
|
}
|
|
@@ -732,7 +732,7 @@ function isPooledClient(raw) {
|
|
|
732
732
|
typeof propertyOf(raw, "savepoint") !== "function" &&
|
|
733
733
|
typeof propertyOf(raw, "release") !== "function");
|
|
734
734
|
}
|
|
735
|
-
const closedConnectionLog = createFallbackLogger("
|
|
735
|
+
const closedConnectionLog = createFallbackLogger("postgres-js");
|
|
736
736
|
// The driver rejects in-flight queries with this AbortError (no code) when the
|
|
737
737
|
// pool closes under them, so isClosedConnectionError can't see it.
|
|
738
738
|
const DRIVER_CLOSED_ABORT_MESSAGE = "The connection was closed.";
|
|
@@ -805,7 +805,7 @@ function logClosedConnection(raw, err, attempts, context) {
|
|
|
805
805
|
});
|
|
806
806
|
}
|
|
807
807
|
// Write and other non-retried paths: log when a closed-connection error leaves
|
|
808
|
-
//
|
|
808
|
+
// the db layer, rethrow unchanged.
|
|
809
809
|
async function unsafeLoggingClosedConnection(db, sqlText, params, context) {
|
|
810
810
|
const raw = asRawClient(db);
|
|
811
811
|
try {
|
|
@@ -821,7 +821,7 @@ function poolMaxOf(raw) {
|
|
|
821
821
|
const max = propertyOf(propertyOf(raw, "options"), "max");
|
|
822
822
|
return typeof max === "number" && Number.isInteger(max) && max > 0 ? max : DEFAULT_POOL_MAX;
|
|
823
823
|
}
|
|
824
|
-
// Exported so raw-SQL query modules outside
|
|
824
|
+
// Exported so raw-SQL query modules outside the db layer (e.g. bundled-features'
|
|
825
825
|
// db/queries/*.ts) can opt into the same closed-connection retry instead of
|
|
826
826
|
// calling asRawClient(db).unsafe(...) directly and losing it.
|
|
827
827
|
// READS ONLY — retry re-executes the statement; never pass INSERT/UPDATE/DELETE.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Snapshot } from "./migrate-generator.ts";
|
|
2
2
|
export type ReplayedTable = {
|
|
3
3
|
readonly columns: ReadonlySet<string>;
|
|
4
|
+
readonly columnsUnknown?: boolean;
|
|
4
5
|
};
|
|
5
6
|
export type ReplayedSchema = ReadonlyMap<string, ReplayedTable>;
|
|
6
7
|
export declare function replayMigrationsDir(migrationsDir: string): ReplayedSchema;
|
|
@@ -67,18 +67,106 @@ function parseColumnNames(body) {
|
|
|
67
67
|
// Clauses that can appear inside an ALTER TABLE body which do NOT change the
|
|
68
68
|
// table/column shape this replay tracks (presence only, not types/
|
|
69
69
|
// constraints/RLS/ownership) — recognized explicitly so they don't fall
|
|
70
|
-
// through to the fail-loud check as "unparsed".
|
|
71
|
-
//
|
|
72
|
-
// replay can't track, so they must keep failing loud.
|
|
70
|
+
// through to the fail-loud check as "unparsed". RENAME TO / RENAME COLUMN
|
|
71
|
+
// are not listed here: they change identity and are applied explicitly.
|
|
73
72
|
const SHAPE_NEUTRAL_ALTER_CLAUSE_RE = new RegExp([
|
|
74
73
|
`ALTER COLUMN\\s+${IDENT}\\s+TYPE\\b`, // #1085 int/bigint-catchup fixes
|
|
75
74
|
`ALTER COLUMN\\s+${IDENT}\\s+(SET|DROP)\\s+NOT NULL\\b`,
|
|
76
75
|
`ALTER COLUMN\\s+${IDENT}\\s+(SET DEFAULT\\b|DROP DEFAULT\\b)`,
|
|
77
76
|
`(ADD|DROP)\\s+CONSTRAINT\\s+${IDENT}`,
|
|
77
|
+
`RENAME\\s+CONSTRAINT\\s+${IDENT}\\s+TO\\s+${IDENT}`,
|
|
78
78
|
`\\b(ENABLE|DISABLE|(NO\\s+)?FORCE)\\s+ROW LEVEL SECURITY\\b`,
|
|
79
79
|
`^OWNER TO\\b`,
|
|
80
80
|
].join("|"), "gi");
|
|
81
|
+
function lastIdentifierSegment(expression) {
|
|
82
|
+
const match = expression.trim().match(new RegExp(`(?:^|\\.)${IDENT}$`));
|
|
83
|
+
return match?.[1];
|
|
84
|
+
}
|
|
85
|
+
// Column names of a `SELECT <list> FROM <table>` as CREATE TABLE AS materializes them: the
|
|
86
|
+
// alias, else the last segment of a plain column reference. `*` copies the single FROM table
|
|
87
|
+
// when the replay knows it; anything else makes the column set unknown.
|
|
88
|
+
function selectListColumns(schema, query) {
|
|
89
|
+
const columns = new Set();
|
|
90
|
+
let columnsUnknown = false;
|
|
91
|
+
const select = query.match(/^\s*SELECT\s+(?:DISTINCT\s+)?([\s\S]*)$/i);
|
|
92
|
+
if (!select?.[1])
|
|
93
|
+
return { columns, columnsUnknown: true };
|
|
94
|
+
let list = select[1];
|
|
95
|
+
const fromAt = topLevelKeywordIndex(list, "FROM");
|
|
96
|
+
const fromClause = fromAt === -1 ? "" : list.slice(fromAt + 4);
|
|
97
|
+
if (fromAt !== -1)
|
|
98
|
+
list = list.slice(0, fromAt);
|
|
99
|
+
for (const item of splitTopLevel(list)) {
|
|
100
|
+
const trimmed = item.trim();
|
|
101
|
+
if (/(^|\.)\*$/.test(trimmed)) {
|
|
102
|
+
const source = fromClause.match(new RegExp(`^\\s*${IDENT}\\s*(?:;|$)`))?.[1];
|
|
103
|
+
const sourceTable = source === undefined ? undefined : schema.get(source);
|
|
104
|
+
if (sourceTable && !sourceTable.columnsUnknown) {
|
|
105
|
+
for (const column of sourceTable.columns)
|
|
106
|
+
columns.add(column);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
columnsUnknown = true;
|
|
110
|
+
}
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const alias = trimmed.match(new RegExp(`\\bAS\\s+${IDENT}\\s*$`, "i"))?.[1];
|
|
114
|
+
const name = alias ?? lastIdentifierSegment(trimmed);
|
|
115
|
+
if (name === undefined)
|
|
116
|
+
columnsUnknown = true;
|
|
117
|
+
else
|
|
118
|
+
columns.add(name);
|
|
119
|
+
}
|
|
120
|
+
return { columns, columnsUnknown };
|
|
121
|
+
}
|
|
122
|
+
function topLevelKeywordIndex(text, keyword) {
|
|
123
|
+
let depth = 0;
|
|
124
|
+
const keywordRe = new RegExp(`^${keyword}\\b`, "i");
|
|
125
|
+
for (let i = 0; i < text.length; i++) {
|
|
126
|
+
const ch = text.charAt(i);
|
|
127
|
+
if (ch === "(")
|
|
128
|
+
depth++;
|
|
129
|
+
else if (ch === ")")
|
|
130
|
+
depth--;
|
|
131
|
+
else if (depth === 0 && /\s/.test(text.charAt(i - 1) || " ") && keywordRe.test(text.slice(i))) {
|
|
132
|
+
return i;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return -1;
|
|
136
|
+
}
|
|
137
|
+
// A DO block's conditions can't be evaluated here; its table DDL is applied as written, since
|
|
138
|
+
// guarded blocks (IF NOT EXISTS ... THEN ALTER TABLE ...) express the intended end state.
|
|
139
|
+
function doBlockTableStatements(statement, file) {
|
|
140
|
+
const body = statement.match(/^DO\s+(?:LANGUAGE\s+\w+\s+)?(\$(?:[A-Za-z_]\w*)?\$)([\s\S]*)\1/i)?.[2];
|
|
141
|
+
if (body === undefined) {
|
|
142
|
+
throw new Error(`${file}: DO block without a recognizable dollar-quoted body — refusing to guess its table DDL`);
|
|
143
|
+
}
|
|
144
|
+
return body
|
|
145
|
+
.split(";")
|
|
146
|
+
.map((chunk) => chunk.match(/\b(?:CREATE|ALTER|DROP)\s+TABLE\b[\s\S]*$/i)?.[0])
|
|
147
|
+
.filter((ddl) => ddl !== undefined)
|
|
148
|
+
.map((ddl) => `${ddl.trim()};`);
|
|
149
|
+
}
|
|
81
150
|
function applyStatement(schema, statement, context) {
|
|
151
|
+
if (/^DO\b/i.test(statement)) {
|
|
152
|
+
for (const nested of doBlockTableStatements(statement, context.file))
|
|
153
|
+
applyStatement(schema, nested, context);
|
|
154
|
+
// skip: DO block fully handled above
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const createAs = statement.match(new RegExp(`^CREATE\\s+(?:UNLOGGED\\s+)?TABLE\\s+(IF NOT EXISTS\\s+)?${IDENT}\\s*(?:\\(([^)]*)\\)\\s*)?AS\\b([\\s\\S]*)$`, "i"));
|
|
158
|
+
if (createAs?.[2] !== undefined && createAs[4] !== undefined) {
|
|
159
|
+
if (!(createAs[1] !== undefined && schema.has(createAs[2]))) {
|
|
160
|
+
const explicitColumns = createAs[3] === undefined ? undefined : parseColumnNames(createAs[3]);
|
|
161
|
+
const selected = selectListColumns(schema, createAs[4]);
|
|
162
|
+
schema.set(createAs[2], {
|
|
163
|
+
columns: explicitColumns ?? selected.columns,
|
|
164
|
+
...(explicitColumns === undefined && selected.columnsUnknown && { columnsUnknown: true }),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
// skip: CREATE TABLE AS fully handled above
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
82
170
|
const create = statement.match(new RegExp(`^CREATE TABLE\\s+(IF NOT EXISTS\\s+)?${IDENT}\\s*\\(([\\s\\S]*)\\);?\\s*$`, "i"));
|
|
83
171
|
if (create?.[2] !== undefined && create[3] !== undefined) {
|
|
84
172
|
const hasIfNotExists = create[1] !== undefined;
|
|
@@ -113,6 +201,23 @@ function applyStatement(schema, statement, context) {
|
|
|
113
201
|
const alterTableName = alterTable?.[1];
|
|
114
202
|
const alterBody = alterTable?.[2];
|
|
115
203
|
if (alterTableName !== undefined && alterBody !== undefined) {
|
|
204
|
+
const renameTable = alterBody.match(new RegExp(`^RENAME\\s+TO\\s+${IDENT}\\s*;?$`, "i"));
|
|
205
|
+
if (renameTable?.[1] !== undefined) {
|
|
206
|
+
const renamed = schema.get(alterTableName) ?? { columns: new Set() };
|
|
207
|
+
schema.delete(alterTableName);
|
|
208
|
+
schema.set(renameTable[1], renamed);
|
|
209
|
+
// skip: ALTER TABLE RENAME TO fully handled above
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const renameColumn = alterBody.match(new RegExp(`^RENAME\\s+(?:COLUMN\\s+)?${IDENT}\\s+TO\\s+${IDENT}\\s*;?$`, "i"));
|
|
213
|
+
if (renameColumn?.[1] !== undefined && renameColumn[2] !== undefined) {
|
|
214
|
+
const renamedIn = schema.get(alterTableName) ?? { columns: new Set() };
|
|
215
|
+
schema.set(alterTableName, renamedIn);
|
|
216
|
+
renamedIn.columns.delete(renameColumn[1]);
|
|
217
|
+
renamedIn.columns.add(renameColumn[2]);
|
|
218
|
+
// skip: ALTER TABLE RENAME COLUMN fully handled above
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
116
221
|
const table = schema.get(alterTableName) ?? { columns: new Set() };
|
|
117
222
|
schema.set(alterTableName, table);
|
|
118
223
|
const clauseRe = new RegExp(`(ADD|DROP)\\s+COLUMN\\s+(?:IF (?:NOT )?EXISTS\\s+)?${IDENT}`, "gi");
|
|
@@ -133,8 +238,8 @@ function applyStatement(schema, statement, context) {
|
|
|
133
238
|
if (alterBody.match(SHAPE_NEUTRAL_ALTER_CLAUSE_RE))
|
|
134
239
|
matchedAClause = true;
|
|
135
240
|
// An ALTER TABLE that matched the outer "ALTER TABLE <name> <body>" shape
|
|
136
|
-
// but whose body contains no recognized clause (e.g.
|
|
137
|
-
//
|
|
241
|
+
// but whose body contains no recognized clause (e.g. SET SCHEMA)
|
|
242
|
+
// would otherwise silently no-op here
|
|
138
243
|
// — fall through to the fail-loud check below instead of returning, so
|
|
139
244
|
// it's reported rather than vanishing.
|
|
140
245
|
// skip: at least one recognized clause matched — this ALTER TABLE is
|
|
@@ -146,19 +251,16 @@ function applyStatement(schema, statement, context) {
|
|
|
146
251
|
// shape this replay tracks — but a statement that clearly INTENDED to
|
|
147
252
|
// touch a table's shape (starts with CREATE/ALTER/DROP TABLE) and matched
|
|
148
253
|
// none of the recognized patterns above must fail loud, not vanish
|
|
149
|
-
// silently. Concretely this is
|
|
150
|
-
// this replay can't track) or genuinely unparsed hand-written DDL — either
|
|
151
|
-
// way a misleading missing-table/column-drift report is worse than
|
|
254
|
+
// silently. Concretely this is genuinely unparsed hand-written DDL — a misleading missing-table/column-drift report is worse than
|
|
152
255
|
// pointing at the actual unparsed statement.
|
|
153
256
|
if (/^(CREATE|ALTER|DROP)\s+TABLE\b/i.test(statement)) {
|
|
154
257
|
const prefix = statement.slice(0, 200).replace(/\s+/g, " ").trim();
|
|
155
258
|
throw new Error(`replayMigrationsDir: unparsed table-DDL statement in ${context.file} — ` +
|
|
156
259
|
`starts with CREATE/ALTER/DROP TABLE but matched none of the replay's ` +
|
|
157
260
|
`recognized patterns (CREATE TABLE, DROP TABLE, ALTER TABLE ADD/DROP ` +
|
|
158
|
-
`COLUMN, ALTER COLUMN ... TYPE, SET/DROP NOT NULL, SET/DROP DEFAULT, ` +
|
|
261
|
+
`COLUMN, RENAME TO/COLUMN, CREATE TABLE AS, DO blocks, ALTER COLUMN ... TYPE, SET/DROP NOT NULL, SET/DROP DEFAULT, ` +
|
|
159
262
|
`ADD/DROP CONSTRAINT, ENABLE/DISABLE/[NO] FORCE ROW LEVEL SECURITY, OWNER TO — ` +
|
|
160
|
-
`optionally-quoted identifiers). Likely
|
|
161
|
-
`identity change, not trackable here) or genuinely unparsed hand-written ` +
|
|
263
|
+
`optionally-quoted identifiers). Likely genuinely unparsed hand-written ` +
|
|
162
264
|
`DDL. Statement: ${prefix}${statement.length > 200 ? "…" : ""}`);
|
|
163
265
|
}
|
|
164
266
|
}
|
|
@@ -194,6 +296,8 @@ export function diffReplayAgainstSnapshot(replayed, snapshot) {
|
|
|
194
296
|
});
|
|
195
297
|
continue;
|
|
196
298
|
}
|
|
299
|
+
if (table.columnsUnknown)
|
|
300
|
+
continue;
|
|
197
301
|
const expected = new Set(meta.columns.map((c) => c.name));
|
|
198
302
|
const missing = [...expected].filter((c) => !table.columns.has(c));
|
|
199
303
|
const extra = [...table.columns].filter((c) => !expected.has(c));
|