@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.
Files changed (76) hide show
  1. package/AGENTS.md +2 -2
  2. package/agents/examples/apex-landing.md +1 -1
  3. package/agents/examples/apex-surface-auth.md +3 -3
  4. package/agents/examples/compose-stacks.md +15 -8
  5. package/agents/examples/embedded-entity-form.md +1 -1
  6. package/agents/examples/notes-history-basic.md +1 -1
  7. package/agents/examples/tags-basic.md +1 -1
  8. package/agents/examples/user-profile.md +1 -1
  9. package/agents/examples/wizard-form.md +4 -4
  10. package/agents/widgets.md +4 -3
  11. package/dist/api/index.d.ts +1 -1
  12. package/dist/api/index.js +1 -1
  13. package/dist/api/routes.js +2 -2
  14. package/dist/api/server.js +2 -1
  15. package/dist/config/index.d.ts +4 -0
  16. package/dist/db/index.d.ts +2 -2
  17. package/dist/db/index.js +1 -1
  18. package/dist/db/migrate-runner.js +12 -4
  19. package/dist/db/money.js +1 -1
  20. package/dist/db/query.js +14 -14
  21. package/dist/db/replay-migration-sql.d.ts +1 -0
  22. package/dist/db/replay-migration-sql.js +115 -11
  23. package/dist/db/tenant-db.d.ts +7 -0
  24. package/dist/db/tenant-db.js +17 -5
  25. package/dist/engine/boot-validator/projection-list-screens.js +28 -37
  26. package/dist/engine/boot-validator/query-refs.js +26 -8
  27. package/dist/engine/boot-validator/screens.d.ts +1 -1
  28. package/dist/engine/boot-validator/screens.js +334 -42
  29. package/dist/engine/boot-validator/url-prefill-fields.js +3 -0
  30. package/dist/engine/build-app-schema.js +7 -3
  31. package/dist/engine/entity-list-searchability.d.ts +5 -0
  32. package/dist/engine/entity-list-searchability.js +35 -0
  33. package/dist/engine/feature-ui-extensions.js +5 -2
  34. package/dist/engine/index.d.ts +3 -1
  35. package/dist/engine/index.js +2 -0
  36. package/dist/engine/paged-list.d.ts +29 -0
  37. package/dist/engine/paged-list.js +56 -0
  38. package/dist/engine/parse-ref-target.js +1 -1
  39. package/dist/engine/project-app-schema-for-roles.js +37 -6
  40. package/dist/engine/provider-plugin-context.d.ts +3 -0
  41. package/dist/engine/provider-plugin-context.js +28 -0
  42. package/dist/engine/schema-builder.js +7 -0
  43. package/dist/engine/types/index.d.ts +2 -2
  44. package/dist/env/index.js +1 -1
  45. package/dist/errors/zod-bridge.js +1 -1
  46. package/dist/files/file-ref-entity.d.ts +6 -6
  47. package/dist/files/file-ref-table.d.ts +6 -6
  48. package/dist/files/index.d.ts +1 -1
  49. package/dist/files/index.js +1 -1
  50. package/dist/files/provider-resolver.d.ts +17 -17
  51. package/dist/files/provider-resolver.js +1 -1
  52. package/dist/http/egress.d.ts +14 -0
  53. package/dist/http/egress.js +144 -0
  54. package/dist/http/index.d.ts +3 -2
  55. package/dist/http/index.js +2 -1
  56. package/dist/http/policy.d.ts +30 -0
  57. package/dist/http/policy.js +299 -0
  58. package/dist/i18n/required-surface-keys.js +52 -2
  59. package/dist/jobs/job-runner.js +4 -2
  60. package/dist/pipeline/config-accessor-factory.d.ts +3 -0
  61. package/dist/pipeline/config-accessor-factory.js +3 -0
  62. package/dist/pipeline/dispatch-shared.js +7 -5
  63. package/dist/pipeline/index.d.ts +1 -0
  64. package/dist/pipeline/index.js +1 -0
  65. package/dist/ui-types/index.d.ts +1 -1
  66. package/dist/upgrade-changelog.d.ts +25 -0
  67. package/dist/upgrade-changelog.js +48 -0
  68. package/dist/upgrade-cli.js +78 -13
  69. package/package.json +3 -5
  70. package/presets/biome-app.json +8 -0
  71. package/src/changes.json +132 -0
  72. package/src/scripts/codemod/README.md +25 -0
  73. package/src/scripts/codemod/codemod-support.ts +64 -6
  74. package/src/scripts/codemod/package-merge.ts +260 -0
  75. package/src/scripts/codemod/s3-imports.ts +4 -42
  76. 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-web` with its short description |
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-web`
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-web";
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-web, hier nur als Referenz, da
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-web";
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
- - composeSaasStack needs the boot option `billing`: set billing: { stripe: { priceToTier }, defaultTier }
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 `billing` or `kmsMode` it fails and names the
62
- stacks.
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 names composeSaasStack and the option", async () => {
128
+ test("a missing billing option only warns and names composeSaasStack", async () => {
126
129
  const { billing: _billing, ...withoutBilling } = bootOptions();
127
- await expect(bootValidate(withoutBilling)).rejects.toThrow(
128
- "composeSaasStack needs the boot option `billing`",
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-web";
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-web";
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-web";
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-web";
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-web graph. Without registering this export, an app
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-web";
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-web";
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-web graph.
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-web/src/index.ts. Do not edit. -->
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-web`.
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 mit var-string-Werten. |
177
+ | `cssVarTokens` | Tokens with var-string values. |
177
178
  | `defaultPrimitives` | |
178
179
  | `defaultTokens` | Default-Tokens — identisch zu `cssVarTokens` (var-string-refs). |
179
180
  | `lightTokens` | |
@@ -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";
@@ -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 (dispatcher-live imports that one) — framework
15
- // cannot depend on headless for four string literals.
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",
@@ -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._configAccessorFactory,
67
+ _configAccessorFactory: configAccessorFactoryOf(context),
67
68
  secrets: context.secrets,
68
69
  db: context.db,
69
70
  ...(cacheSync ? { cacheSync } : {}),
@@ -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. */
@@ -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 === "$" && /^\$([A-Za-z_]\w*)?\$/.test(sqlText.slice(i))) {
115
- throw new Error("splitSqlStatements: unsupported dollar-quoted body — migration SQL is malformed, refusing to split");
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-web/primitives/index.tsx,
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
- // Bun.SQL tx/savepoint handles keep a begin() that throws; only savepoint() tells them apart from a pool.
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 / Bun.SQL reject the WHOLE begin() when any statement inside
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 / Bun.SQL).");
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("bun-db.extractTableInfo: table is not a kumiko EntityTableMeta — " +
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 against Bun.SQL (kumiko-framework#1924): a `date`
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("bun-db");
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
- // Bun.SQL / some drivers return int4 as bigint or numeric string.
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
- // postgres.js truncates timestamptz params to ms via Date; Bun.SQL sends µs and Postgres
473
- // rounds into timestamptz(3). Truncating here keeps both drivers storing the same instant.
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
- // Bun.SQL binds JS numbers typed, so Postgres would round 3.5 into an integer column; postgres.js sends text and rejects it.
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 under Bun.SQL).
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("bun-db");
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
- // bun-db, rethrow unchanged.
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 bun-db (e.g. bundled-features'
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". Deliberately does NOT
71
- // include RENAME TO / RENAME COLUMN: those DO change identity in a way this
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. RENAME TO/RENAME
137
- // COLUMN, which DO change identity) would otherwise silently no-op here
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 RENAME TO / RENAME COLUMN (identity change
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 RENAME TO/RENAME COLUMN (real ` +
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));