@ultimat3/cli 22.15.0 → 24.0.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/CLAUDE.md +36 -13
- package/README.md +31 -8
- package/package.json +29 -28
- package/src/admin-catalog-keys.ts +16 -0
- package/src/admin-registration.ts +192 -0
- package/src/affected.ts +27 -35
- package/src/api-registration.ts +125 -11
- package/src/app-artifacts.ts +1 -1
- package/src/app-auth.ts +4 -27
- package/src/app-boundaries.ts +6 -20
- package/src/app-config-load.ts +51 -0
- package/src/app-env.ts +5 -11
- package/src/app-load.ts +55 -11
- package/src/app-manifest.ts +16 -8
- package/src/app-mcp.ts +10 -23
- package/src/app-permissions-borrowed.ts +129 -0
- package/src/app-permissions-site.ts +24 -0
- package/src/app-permissions.ts +0 -0
- package/src/app-reload-graph.ts +7 -30
- package/src/app-transport.ts +219 -0
- package/src/boundary-findings.ts +16 -4
- package/src/browser-transport.ts +160 -0
- package/src/budgets.ts +27 -34
- package/src/cdp-shot-driver.ts +3 -1
- package/src/cmd-auth-spec.ts +16 -0
- package/src/cmd-auth.ts +65 -0
- package/src/cmd-build-spec.ts +10 -3
- package/src/cmd-build.ts +107 -11
- package/src/cmd-db-backfill.ts +25 -12
- package/src/cmd-db.ts +66 -16
- package/src/cmd-deploy-helm.ts +7 -6
- package/src/cmd-deploy.ts +4 -4
- package/src/cmd-dev.ts +9 -8
- package/src/cmd-doctor-spec.ts +2 -9
- package/src/cmd-doctor.ts +75 -24
- package/src/cmd-env.ts +26 -2
- package/src/cmd-generate.ts +195 -47
- package/src/cmd-help.ts +19 -6
- package/src/cmd-i18n.ts +10 -9
- package/src/cmd-jobs-spec.ts +19 -3
- package/src/cmd-jobs.ts +136 -7
- package/src/cmd-manifest-spec.ts +1 -1
- package/src/cmd-manifest.ts +6 -25
- package/src/cmd-mcp.ts +46 -15
- package/src/cmd-new.ts +15 -1
- package/src/cmd-planned.ts +15 -8
- package/src/cmd-routes.ts +27 -3
- package/src/cmd-secrets-ring.ts +76 -0
- package/src/cmd-secrets-spec.ts +10 -2
- package/src/cmd-secrets.ts +95 -18
- package/src/cmd-shot-matrix.ts +1 -3
- package/src/cmd-shot.ts +12 -3
- package/src/cmd-tasks.ts +84 -15
- package/src/cmd-test-spec.ts +1 -7
- package/src/cmd-test.ts +13 -29
- package/src/cmd-verify.ts +13 -5
- package/src/comment-safe.ts +16 -0
- package/src/coverage-floor.ts +334 -0
- package/src/coverage-lcov.ts +193 -0
- package/src/coverage-source.ts +136 -0
- package/src/db-object-drift.ts +30 -0
- package/src/db-replay-engine.ts +115 -0
- package/src/db-schema-dump.ts +150 -0
- package/src/db-schema-refresh.ts +122 -0
- package/src/db-subscribes.ts +12 -4
- package/src/dev-dashboard-guard.ts +76 -0
- package/src/dev-dashboard.ts +51 -29
- package/src/dev-lock.ts +4 -7
- package/src/dev-route-table.ts +9 -0
- package/src/dispatch.ts +3 -6
- package/src/doctor-app-url.ts +39 -0
- package/src/doctor-auth.ts +95 -0
- package/src/doctor-coverage.ts +39 -0
- package/src/doctor-guards.ts +54 -0
- package/src/doctor-sealed.ts +132 -0
- package/src/document-graph.ts +82 -0
- package/src/drift-replica-identity.ts +42 -0
- package/src/error-catalog.ts +6 -9
- package/src/error-codes.ts +55 -3
- package/src/error-unthrown.ts +29 -11
- package/src/errors.ts +54 -83
- package/src/framework-schema-apply.ts +78 -0
- package/src/framework-schema-stamp.ts +96 -0
- package/src/framework-schema.ts +28 -2
- package/src/generate-files.ts +79 -12
- package/src/generate-grant-findings.ts +76 -0
- package/src/generate-grants.ts +77 -3
- package/src/generate-kinds.ts +52 -1
- package/src/generate-shadow.ts +46 -0
- package/src/generate-write.ts +62 -40
- package/src/generated-imports.ts +59 -0
- package/src/gh-target.ts +12 -7
- package/src/guard-sources.ts +104 -0
- package/src/guards.ts +23 -7
- package/src/handle-registration.ts +217 -0
- package/src/i18n-audit.ts +4 -1
- package/src/i18n-registration.ts +36 -0
- package/src/image-prepare.ts +51 -8
- package/src/import-closure.ts +165 -0
- package/src/index.ts +12 -5
- package/src/island-bundle.ts +3 -15
- package/src/island-store.ts +24 -16
- package/src/jobs-drain.ts +95 -21
- package/src/jobs-json.ts +50 -0
- package/src/jobs-report.ts +7 -4
- package/src/json-merge.ts +46 -0
- package/src/loopback-host.ts +13 -0
- package/src/manifest-staleness.ts +62 -0
- package/src/mcp-errors.ts +41 -4
- package/src/mcp-host.ts +16 -9
- package/src/mcp-test-run.ts +38 -0
- package/src/messages.ts +35 -0
- package/src/metrics-endpoint.ts +95 -24
- package/src/migration-extensions.ts +29 -0
- package/src/module-imports.ts +47 -0
- package/src/output.ts +24 -9
- package/src/page-navigation.ts +7 -23
- package/src/page-speculation.ts +5 -20
- package/src/permission-grants.ts +41 -9
- package/src/prerender-out.ts +34 -7
- package/src/prerender.ts +8 -5
- package/src/publish-closure.ts +101 -0
- package/src/pwa-artifacts.ts +26 -94
- package/src/registry.ts +2 -0
- package/src/revalidated-response.ts +4 -3
- package/src/role-load.ts +143 -0
- package/src/role-replicator.ts +16 -0
- package/src/role-start.ts +27 -5
- package/src/role-sync.ts +55 -20
- package/src/role-wake.ts +45 -0
- package/src/runtime-admin.ts +141 -0
- package/src/runtime-assets.ts +11 -7
- package/src/runtime-bindings.ts +4 -4
- package/src/runtime-cache.ts +38 -47
- package/src/runtime-hooks.ts +5 -2
- package/src/runtime-idempotency-scope.ts +23 -0
- package/src/runtime-isr.ts +36 -0
- package/src/runtime-jobs.ts +8 -33
- package/src/runtime-mfa-warning.ts +29 -0
- package/src/runtime-notify-retention.ts +10 -46
- package/src/runtime-overrides.ts +1 -1
- package/src/runtime-purge.ts +38 -16
- package/src/runtime-queue.ts +39 -11
- package/src/runtime-realtime.ts +9 -45
- package/src/runtime-render.ts +68 -35
- package/src/runtime-services.ts +22 -18
- package/src/runtime-storage.ts +10 -5
- package/src/scaffold-typecheck.ts +1 -0
- package/src/schema-diff.ts +16 -0
- package/src/schema-drift.ts +45 -15
- package/src/schema-dump-drift.ts +64 -0
- package/src/seo-routes.ts +80 -9
- package/src/serve-boot.ts +114 -158
- package/src/serve-drain.ts +9 -33
- package/src/serve-env.ts +9 -6
- package/src/serve-prebuilt-paths.ts +19 -0
- package/src/serve-prebuilt.ts +84 -0
- package/src/serve-web.ts +169 -0
- package/src/serve.ts +6 -9
- package/src/server-barrels.ts +54 -0
- package/src/shot-locale.ts +5 -23
- package/src/site-assets.ts +3 -5
- package/src/site-config.ts +9 -48
- package/src/site-seo.ts +1 -1
- package/src/sitemap-lastmod.ts +5 -0
- package/src/solid-loader.ts +12 -3
- package/src/source-edit.ts +49 -0
- package/src/source-files.ts +6 -0
- package/src/stored-object-headers.ts +43 -0
- package/src/sync-url.ts +2 -2
- package/src/tasks-facts.ts +3 -26
- package/src/templates/action.ts +147 -11
- package/src/templates/admin-catalog.ts +58 -0
- package/src/templates/admin-page.ts +36 -8
- package/src/templates/admin.ts +57 -17
- package/src/templates/backfill.ts +121 -87
- package/src/templates/entity.ts +140 -44
- package/src/templates/guard-animated-layout-property.ts +4 -11
- package/src/templates/guard-bare-error.ts +9 -13
- package/src/templates/guard-focus-visible.ts +4 -11
- package/src/templates/guard-image-dimensions.ts +6 -6
- package/src/templates/guard-island-without-states.ts +2 -4
- package/src/templates/guard-raw-breakpoint.ts +210 -0
- package/src/templates/guard-raw-colour.ts +4 -11
- package/src/templates/guard-raw-length.ts +375 -0
- package/src/templates/guard-raw-motion.ts +213 -0
- package/src/templates/guard-raw-shadow.ts +187 -0
- package/src/templates/guard-raw-z-index.ts +166 -0
- package/src/templates/guard-repo-raw-sql.ts +345 -0
- package/src/templates/guard-semantic-interactive.ts +6 -6
- package/src/templates/guard-undeclared-custom-property.ts +217 -0
- package/src/templates/guard-undefined-style-class.ts +215 -0
- package/src/templates/guard-untranslated-string.ts +6 -6
- package/src/templates/guard-unzoned-date.ts +9 -13
- package/src/templates/index.ts +3 -2
- package/src/templates/island.ts +34 -51
- package/src/templates/job-body-test.ts +121 -0
- package/src/templates/job.ts +23 -8
- package/src/templates/naming.ts +28 -0
- package/src/templates/policy.ts +16 -9
- package/src/templates/query.ts +242 -26
- package/src/templates/resource-create.ts +62 -18
- package/src/templates/resource-form-island.ts +58 -80
- package/src/templates/resource-page.ts +269 -0
- package/src/templates/resource-service.ts +96 -0
- package/src/templates/resource.ts +86 -61
- package/src/templates/route.ts +41 -35
- package/src/templates/scaffold-api.ts +55 -1
- package/src/templates/scaffold-app.ts +177 -53
- package/src/templates/scaffold-auth.ts +83 -19
- package/src/templates/scaffold-browser-client.ts +90 -0
- package/src/templates/scaffold-container-compose.ts +10 -1
- package/src/templates/scaffold-container.ts +20 -2
- package/src/templates/scaffold-dashboard-bare.ts +64 -2
- package/src/templates/scaffold-dashboard-example.ts +124 -31
- package/src/templates/scaffold-dashboard-shared.ts +1 -20
- package/src/templates/scaffold-dashboard.ts +1 -2
- package/src/templates/scaffold-db-client.ts +144 -0
- package/src/templates/scaffold-db-package.ts +90 -8
- package/src/templates/scaffold-docs.ts +50 -5
- package/src/templates/scaffold-guards.ts +54 -11
- package/src/templates/scaffold-helm-fences.ts +76 -0
- package/src/templates/scaffold-helm-templates.ts +32 -7
- package/src/templates/scaffold-helm.ts +31 -1
- package/src/templates/scaffold-http.ts +9 -7
- package/src/templates/scaffold-repo.ts +61 -3
- package/src/templates/scaffold-roles.ts +56 -9
- package/src/templates/scaffold-shell.ts +74 -105
- package/src/templates/scaffold-site.ts +45 -19
- package/src/templates/scaffold-ui-package.ts +18 -0
- package/src/templates/slice-foundation.ts +46 -8
- package/src/test-counts.ts +14 -2
- package/src/test-ignores.ts +25 -0
- package/src/test-passes.ts +0 -15
- package/src/test-select.ts +5 -14
- package/src/test-shards.ts +41 -93
- package/src/test-workers.ts +26 -4
- package/src/theme-boot.ts +6 -31
- package/src/transport-calls.ts +105 -0
- package/src/trusted-hops-error.ts +17 -0
- package/src/unscanned-admin.ts +54 -0
- package/src/unthrown-codes.ts +26 -0
- package/src/verify-checks.ts +43 -53
- package/src/verify-coverage-run.ts +190 -0
- package/src/verify-deadline.ts +353 -0
- package/src/verify-floor.ts +36 -9
- package/src/verify-merge.ts +171 -45
- package/src/verify-part-step.ts +90 -0
- package/src/verify-progress.ts +25 -0
- package/src/verify-role-load-probe.ts +54 -0
- package/src/verify-role-load.ts +148 -0
- package/src/verify-run.ts +116 -37
- package/src/verify-shard.ts +33 -11
- package/src/verify-stalled.ts +152 -0
- package/src/verify-step.ts +42 -1
- package/src/verify-tests.ts +83 -8
- package/src/workspace-checks.ts +50 -8
- package/src/workspace-dep-edit.ts +127 -0
- package/src/build-errors.ts +0 -54
- package/src/cmd-dev-fixture.ts +0 -204
- package/src/dev-live-fixture.ts +0 -122
- package/src/policy-fixture.ts +0 -78
- package/src/realtime-browser-probe-fixture.ts +0 -9
- package/src/role-start-fixture.ts +0 -78
- package/src/scaffold-fixture.ts +0 -160
- package/src/templates/island-fixture.ts +0 -76
- package/src/verify-errors.ts +0 -49
package/src/runtime-queue.ts
CHANGED
|
@@ -7,16 +7,17 @@
|
|
|
7
7
|
import {
|
|
8
8
|
type PostgresIdempotencyStore,
|
|
9
9
|
postgresIdempotencyStore,
|
|
10
|
+
requestDeadlineMs,
|
|
10
11
|
resetIdempotency,
|
|
11
12
|
setIdempotencyStore,
|
|
12
13
|
} from '@ultimat3/action';
|
|
13
14
|
import type { DbClient, PgliteClient, PostgresClient, SqlFragment } from '@ultimat3/db';
|
|
14
15
|
import {
|
|
16
|
+
baseClient,
|
|
15
17
|
createPgliteClient,
|
|
16
18
|
createPostgresClient,
|
|
17
19
|
currentTx,
|
|
18
20
|
pgliteDataDir,
|
|
19
|
-
raw,
|
|
20
21
|
setDbClient,
|
|
21
22
|
} from '@ultimat3/db';
|
|
22
23
|
import type { Tx } from '@ultimat3/entity';
|
|
@@ -26,13 +27,15 @@ import {
|
|
|
26
27
|
createPgDriver,
|
|
27
28
|
createPgEventBus,
|
|
28
29
|
createPgOutboxStore,
|
|
30
|
+
resetEventBus,
|
|
29
31
|
resetJobDriver,
|
|
30
32
|
resetJobsFacade,
|
|
31
33
|
setEventBus,
|
|
32
34
|
setJobDriver,
|
|
33
35
|
setJobsFacade,
|
|
34
36
|
} from '@ultimat3/jobs';
|
|
35
|
-
import {
|
|
37
|
+
import { applyLockedSchema, verifySchema } from './framework-schema-apply';
|
|
38
|
+
import { appExtensions } from './migration-extensions';
|
|
36
39
|
import type { DevServices } from './runtime-bindings';
|
|
37
40
|
import type { RuntimeOverrides } from './runtime-overrides';
|
|
38
41
|
import { attachReplica, type ReplicaEnv, replicaUrlFor } from './runtime-replica';
|
|
@@ -93,7 +96,13 @@ export function startDb(services: DevServices, env: ReplicaEnv): StartedDb {
|
|
|
93
96
|
binding.mode === 'embedded'
|
|
94
97
|
? // `pgliteDataDir` is `@ultimat3/db`'s own reader of the `pglite://` form; a second parser
|
|
95
98
|
// here is a second thing to keep right when the form changes.
|
|
96
|
-
|
|
99
|
+
// Linked at boot, from the app's own migrations: PGlite cannot `create extension` one it
|
|
100
|
+
// was not handed, and the schema dump's scratch replay already asks this same reader — an
|
|
101
|
+
// app that replayed in the gate and failed in `x dev` was the two disagreeing.
|
|
102
|
+
createPgliteClient({
|
|
103
|
+
dataDir: pgliteDataDir(binding.url),
|
|
104
|
+
extensions: () => appExtensions(services.root),
|
|
105
|
+
})
|
|
97
106
|
: createPostgresClient({ url: binding.url });
|
|
98
107
|
const attached = attachReplica(client, replicaUrlFor(binding, env));
|
|
99
108
|
setDbClient(attached.client);
|
|
@@ -127,10 +136,18 @@ export function pgExecutorFor(client: DbClient): PgExecutor {
|
|
|
127
136
|
* `@ultimat3/notify` because a package that holds no database dependency cannot apply its own
|
|
128
137
|
* schema — the same reason `SQL_JOBS_TABLE` is applied by the boot.
|
|
129
138
|
*/
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
139
|
+
/**
|
|
140
|
+
* `'apply'` — `ROLE=migrate`, `x dev` and every CLI command — runs the DDL under the migration
|
|
141
|
+
* lock with a bounded `lock_timeout` and stamps the build. `'verify'` — a serving role on an
|
|
142
|
+
* external database — runs none: an `alter table` queued behind one open transaction stalled every
|
|
143
|
+
* enqueue in the fleet on each pod start, and racing `create table`s were a first-deploy `23505`.
|
|
144
|
+
*/
|
|
145
|
+
export type SchemaMode = 'apply' | 'verify';
|
|
133
146
|
|
|
147
|
+
async function applySchema(client: DevDbClient, mode: SchemaMode): Promise<void> {
|
|
148
|
+
if (mode === 'verify') await verifySchema(client);
|
|
149
|
+
else await applyLockedSchema(client);
|
|
150
|
+
}
|
|
134
151
|
/**
|
|
135
152
|
* The dev queue is the real Postgres queue on the embedded Postgres — claiming, leases and the
|
|
136
153
|
* one-live-job-per-key index all behave here exactly as in production. A memory queue in dev
|
|
@@ -162,9 +179,10 @@ async function applySchema(client: DevDbClient): Promise<void> {
|
|
|
162
179
|
async function startJobs(
|
|
163
180
|
client: DevDbClient,
|
|
164
181
|
replica: PostgresClient | undefined,
|
|
165
|
-
overrides
|
|
182
|
+
overrides: RuntimeOverrides | undefined,
|
|
183
|
+
schema: SchemaMode,
|
|
166
184
|
): Promise<RunningQueue> {
|
|
167
|
-
await applySchema(client);
|
|
185
|
+
await applySchema(client, schema);
|
|
168
186
|
const executor = pgExecutorFor(client);
|
|
169
187
|
const driver = overrides?.jobs ?? createPgDriver({ executor });
|
|
170
188
|
setJobDriver(driver);
|
|
@@ -178,7 +196,14 @@ async function startJobs(
|
|
|
178
196
|
);
|
|
179
197
|
const events = createPgEventBus({ executor });
|
|
180
198
|
setEventBus(events);
|
|
181
|
-
const idempotency = postgresIdempotencyStore({
|
|
199
|
+
const idempotency = postgresIdempotencyStore({
|
|
200
|
+
executor,
|
|
201
|
+
// `baseClient`, read per call and never `client`: a transaction's origin is the AMBIENT client
|
|
202
|
+
// `startDb` installed, which is the replicated pair when a standby is configured.
|
|
203
|
+
origin: baseClient,
|
|
204
|
+
// The app's own `requestTimeoutMs`, read per reservation — it is declared after this boot.
|
|
205
|
+
reclaimAfterMs: requestDeadlineMs,
|
|
206
|
+
});
|
|
182
207
|
setIdempotencyStore(idempotency);
|
|
183
208
|
return {
|
|
184
209
|
db: client,
|
|
@@ -197,7 +222,8 @@ async function startJobs(
|
|
|
197
222
|
* startup entirely, so every query it makes fails on a connection this process already dropped.
|
|
198
223
|
*
|
|
199
224
|
* The facade goes with the driver for the same reason: an enqueue routed through a store bound to
|
|
200
|
-
* a closed client is a staged row nothing will ever publish
|
|
225
|
+
* a closed client is a staged row nothing will ever publish — and the event bus with both, or a
|
|
226
|
+
* `publishEvent` after `stop()` runs over the closed pool.
|
|
201
227
|
*/
|
|
202
228
|
async function releaseQueue(
|
|
203
229
|
db: DevDbClient,
|
|
@@ -208,6 +234,7 @@ async function releaseQueue(
|
|
|
208
234
|
// holds this client, and the next command in this process would reserve keys over a closed one.
|
|
209
235
|
resetIdempotency();
|
|
210
236
|
resetJobsFacade();
|
|
237
|
+
resetEventBus();
|
|
211
238
|
resetJobDriver();
|
|
212
239
|
setDbClient(undefined);
|
|
213
240
|
await jobs?.close?.();
|
|
@@ -232,13 +259,14 @@ export async function startQueue(
|
|
|
232
259
|
* this file reads it: see `startDb` for what two readers of one question cost.
|
|
233
260
|
*/
|
|
234
261
|
env: ReplicaEnv = process.env,
|
|
262
|
+
schema: SchemaMode = 'apply',
|
|
235
263
|
): Promise<RunningQueue> {
|
|
236
264
|
const { client: db, replica } = startDb(services, env);
|
|
237
265
|
try {
|
|
238
266
|
// Pay the Postgres boot here, so the first request is not the slow one and a broken database
|
|
239
267
|
// fails at boot rather than on some later query.
|
|
240
268
|
await db.ping();
|
|
241
|
-
return await startJobs(db, replica, overrides);
|
|
269
|
+
return await startJobs(db, replica, overrides, schema);
|
|
242
270
|
} catch (error) {
|
|
243
271
|
// `db.ping()` or `startJobs` is where a broken database is supposed to fail. Without this,
|
|
244
272
|
// the caller exits holding the PGlite lock and the ambient accessors, and nothing is left to
|
package/src/runtime-realtime.ts
CHANGED
|
@@ -1,55 +1,19 @@
|
|
|
1
|
-
// `realtime.enabled`, `realtime.transport` and `realtime.urlEnv
|
|
2
|
-
// `app
|
|
3
|
-
//
|
|
4
|
-
// plain object. Until 22.0.0 no boot read any of the three and `NATS_URL` alone decided the bus.
|
|
1
|
+
// `realtime.enabled`, `realtime.transport` and `realtime.urlEnv` for the boot, out of the config
|
|
2
|
+
// `startServices` loads once (`app-config-load.ts`). Until 22.0.0 no boot read any of the three
|
|
3
|
+
// and `NATS_URL` alone decided the bus.
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
// `pathToFileURL` and `fileURLToPath`, and nothing that joins a path.
|
|
8
|
-
import { join } from 'node:path';
|
|
9
|
-
import { ConfigInvalidError, type RealtimeConfig, type RealtimeTransport } from '@ultimat3/core';
|
|
10
|
-
import { APP_CONFIG_EXPORT } from './app-auth';
|
|
11
|
-
import { APP_CONFIG_FILE } from './app-root';
|
|
5
|
+
import type { AppConfig, RealtimeConfig } from '@ultimat3/core';
|
|
12
6
|
|
|
13
|
-
/** Core's `defaults()` for the section
|
|
7
|
+
/** Core's `defaults()` for the section — the answer for a root with no `app.config.ts`. */
|
|
14
8
|
export const REALTIME_DEFAULTS: RealtimeConfig = Object.freeze({
|
|
15
9
|
enabled: true,
|
|
16
10
|
transport: 'memory',
|
|
17
11
|
urlEnv: undefined,
|
|
18
12
|
});
|
|
19
13
|
|
|
20
|
-
const TRANSPORTS: readonly RealtimeTransport[] = ['memory', 'nats'];
|
|
21
|
-
|
|
22
|
-
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
23
|
-
typeof value === 'object' && value !== null;
|
|
24
|
-
|
|
25
|
-
/** A transport this boot can build, or a refusal — never a guess at a bus. */
|
|
26
|
-
function transportOf(section: Record<string, unknown>): RealtimeTransport {
|
|
27
|
-
const value = section['transport'];
|
|
28
|
-
if (value === undefined) return REALTIME_DEFAULTS.transport;
|
|
29
|
-
const known = TRANSPORTS.find((transport) => transport === value);
|
|
30
|
-
if (known !== undefined) return known;
|
|
31
|
-
throw new ConfigInvalidError({
|
|
32
|
-
cause: `realtime.transport in ${APP_CONFIG_FILE} is not one of ${TRANSPORTS.join(', ')}`,
|
|
33
|
-
fix: `set realtime: { transport: 'memory' } in ${APP_CONFIG_FILE}, or { transport: 'nats', urlEnv: 'NATS_URL' } for more than one node`,
|
|
34
|
-
meta: { key: 'realtime.transport' },
|
|
35
|
-
});
|
|
36
|
-
}
|
|
37
|
-
|
|
38
14
|
/**
|
|
39
|
-
* The section as the boot obeys it
|
|
40
|
-
*
|
|
15
|
+
* The section as the boot obeys it — `defineConfig`'s per-key merge, so the boot and the app's own
|
|
16
|
+
* config object agree, and a transport nothing builds is core's `X_CONFIG_INVALID`.
|
|
41
17
|
*/
|
|
42
|
-
export
|
|
43
|
-
|
|
44
|
-
if (!(await Bun.file(path).exists())) return REALTIME_DEFAULTS;
|
|
45
|
-
const module = (await import(path)) as Record<string, unknown>;
|
|
46
|
-
const config = module[APP_CONFIG_EXPORT];
|
|
47
|
-
const section = isRecord(config) ? config['realtime'] : undefined;
|
|
48
|
-
if (!isRecord(section)) return REALTIME_DEFAULTS;
|
|
49
|
-
const { enabled, urlEnv } = section;
|
|
50
|
-
return {
|
|
51
|
-
enabled: typeof enabled === 'boolean' ? enabled : REALTIME_DEFAULTS.enabled,
|
|
52
|
-
transport: transportOf(section),
|
|
53
|
-
urlEnv: typeof urlEnv === 'string' && urlEnv.trim() !== '' ? urlEnv.trim() : undefined,
|
|
54
|
-
};
|
|
55
|
-
}
|
|
18
|
+
export const realtimeConfigOf = (config: AppConfig | undefined): RealtimeConfig =>
|
|
19
|
+
config?.realtime ?? REALTIME_DEFAULTS;
|
package/src/runtime-render.ts
CHANGED
|
@@ -1,35 +1,28 @@
|
|
|
1
|
-
// Projecting the route table onto HTTP routes
|
|
1
|
+
// Projecting the route table onto HTTP routes both boots serve. Every mode goes through
|
|
2
2
|
// `@ultimat3/render`'s own function for that mode — the CLI picks the mode and supplies the
|
|
3
|
-
// document
|
|
4
|
-
//
|
|
5
|
-
// The document is head + the route's own component, rendered by `@ultimat3/render`'s server JSX
|
|
6
|
-
// writer, with the surface's compiled CSS LINKED — one content-hashed file per surface
|
|
7
|
-
// (`style-bundle.ts`), served `immutable`.
|
|
8
|
-
//
|
|
9
|
-
// It was inlined until 2026-09-06, on the argument that a `site/` page is a 0kb-JS artifact a CDN
|
|
10
|
-
// serves as one file and a link would add a round trip. The round trip is real and it is paid
|
|
11
|
-
// once: measured against ai-maxxing, every `app/` document carried the SAME 156,738-byte `<style>`
|
|
12
|
-
// block — 92% of the dashboard document — inside a response the pipeline sends
|
|
13
|
-
// `Cache-Control: private, no-store`, so the trip that argument saved was re-paid in full on every
|
|
14
|
-
// navigation, with a re-parse on top. The static export writes the file (`writeStyles`), so the
|
|
15
|
-
// "second file" cost is one `Bun.write`.
|
|
3
|
+
// document (head + the route's component, the surface's CSS LINKED, `style-bundle.ts`), it never
|
|
4
|
+
// decides what a mode means or what headers it earns.
|
|
16
5
|
|
|
17
6
|
// why: Bun ships no path API; an island's file is its route file's directory joined to its `src`.
|
|
18
7
|
import { posix } from 'node:path';
|
|
8
|
+
import { actionPathStyle } from '@ultimat3/action';
|
|
19
9
|
import { clientScopeOf } from '@ultimat3/auth';
|
|
20
10
|
import type { Ctx } from '@ultimat3/core';
|
|
21
|
-
import { CLIENT_SCOPE_HEADER } from '@ultimat3/core';
|
|
11
|
+
import { CLIENT_SCOPE_HEADER, createSingleFlight } from '@ultimat3/core';
|
|
22
12
|
import type {
|
|
23
13
|
RouteMeta as HttpRouteMeta,
|
|
24
14
|
RedirectIntent,
|
|
15
|
+
RequestContext,
|
|
25
16
|
Route,
|
|
26
17
|
RouteParams,
|
|
18
|
+
UltimateRequest,
|
|
27
19
|
} from '@ultimat3/http';
|
|
28
20
|
import { asCtx, html, NO_STORE, redirect, stream, takeRedirect } from '@ultimat3/http';
|
|
29
21
|
import { currentLocale, localeConfig } from '@ultimat3/i18n';
|
|
30
22
|
import type { IslandCollector, RenderResult, RouteData, RouteEntry } from '@ultimat3/render';
|
|
31
23
|
import {
|
|
32
24
|
clientBootTags,
|
|
25
|
+
clientPathStyleTags,
|
|
33
26
|
clientPersistTags,
|
|
34
27
|
clientScopeTag,
|
|
35
28
|
clientSyncTags,
|
|
@@ -50,7 +43,6 @@ import {
|
|
|
50
43
|
import type { IsrController } from '@ultimat3/render/server';
|
|
51
44
|
import {
|
|
52
45
|
contentHash,
|
|
53
|
-
createIsrController,
|
|
54
46
|
isrKey,
|
|
55
47
|
ROOT_ELEMENT_ID,
|
|
56
48
|
renderComponent,
|
|
@@ -67,6 +59,7 @@ import {
|
|
|
67
59
|
navigationTagsOf,
|
|
68
60
|
principalRelocation,
|
|
69
61
|
} from './page-navigation';
|
|
62
|
+
import { attachedIsr } from './runtime-isr';
|
|
70
63
|
import type { StaticResult } from './static-document';
|
|
71
64
|
import { createStaticMemo, staticMemoKey, staticResponse } from './static-document';
|
|
72
65
|
import { styleBundle } from './style-bundle';
|
|
@@ -75,7 +68,11 @@ export type { DocumentOptions, IslandResolver } from './document-options';
|
|
|
75
68
|
|
|
76
69
|
export interface DevRenderOptions extends DocumentOptions {
|
|
77
70
|
readonly buildId: string;
|
|
78
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* The boot's own controller, attached and released by it (`runtime-isr.ts`). Omitted, one is
|
|
73
|
+
* built AND attached here, never released — a caller with no stop list (a contract test) still
|
|
74
|
+
* gets pages a tag bust reaches; the revalidator slot is the latest attach's.
|
|
75
|
+
*/
|
|
79
76
|
readonly isr?: IsrController;
|
|
80
77
|
/**
|
|
81
78
|
* Keep each `static` document after its first render (`static-document.ts`). The container sets
|
|
@@ -130,6 +127,10 @@ const headFor = async (
|
|
|
130
127
|
}),
|
|
131
128
|
[
|
|
132
129
|
...(options.sync === undefined ? [] : clientSyncTags(options.sync)),
|
|
130
|
+
// Every document, shareable ones included: the style is the app's, not a visitor's, and
|
|
131
|
+
// it is the only way an island's `rpc()` learns it. Read per render — `defineApi` may run
|
|
132
|
+
// after these routes are built. `'resource'`, the default, is no tag.
|
|
133
|
+
...clientPathStyleTags(actionPathStyle()),
|
|
133
134
|
...router,
|
|
134
135
|
// The page boot rides the scope tag: its whole job — restoring a principal's persisted
|
|
135
136
|
// records and replaying its queued writes — is per principal, and a shareable document
|
|
@@ -150,7 +151,8 @@ const headFor = async (
|
|
|
150
151
|
|
|
151
152
|
/**
|
|
152
153
|
* `<link rel="stylesheet">` for the surface's own stylesheets, or nothing at all when the surface
|
|
153
|
-
* imports none.
|
|
154
|
+
* imports none. Inlined until 2026-09-06: every `app/` document of ai-maxxing then carried the same
|
|
155
|
+
* 156,738-byte `<style>` (92% of it) under `no-store`, re-paid on every navigation.
|
|
154
156
|
*
|
|
155
157
|
* In `<head>`, which is what keeps this a swap and not a regression: a `<link rel="stylesheet">`
|
|
156
158
|
* there is render-blocking in every browser, exactly as the inline block was, so there is no
|
|
@@ -423,8 +425,11 @@ const metaOf = (entry: RouteEntry, head?: NavigationDocumentHead): HttpRouteMeta
|
|
|
423
425
|
* only a test does — and then guard and page fall back together.
|
|
424
426
|
*/
|
|
425
427
|
export function appRoutes(options: DevRenderOptions): readonly Route[] {
|
|
426
|
-
const isr = options.isr ??
|
|
428
|
+
const isr = options.isr ?? attachedIsr({ buildId: options.buildId }).isr;
|
|
427
429
|
const memo = createStaticMemo();
|
|
430
|
+
// A cold static key is ONE render however many requests arrive during it: the memo alone was
|
|
431
|
+
// check-then-act, so a burst at boot ran `load` and the render once per request.
|
|
432
|
+
const firstRender = createSingleFlight();
|
|
428
433
|
return routeEntries().map((registered) => ({
|
|
429
434
|
method: 'GET' as const,
|
|
430
435
|
path: registered.path,
|
|
@@ -444,23 +449,51 @@ export function appRoutes(options: DevRenderOptions): readonly Route[] {
|
|
|
444
449
|
: undefined;
|
|
445
450
|
const kept = key === undefined ? undefined : memo.get(key);
|
|
446
451
|
if (kept !== undefined) return staticResponse(request, kept);
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
const
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
return responseOf(result);
|
|
459
|
-
}
|
|
460
|
-
const document: StaticResult = { ...result, body: result.body };
|
|
461
|
-
if (key !== undefined) memo.set(key, document);
|
|
452
|
+
let led = false;
|
|
453
|
+
const render = async (): Promise<Rendered> => {
|
|
454
|
+
led = true;
|
|
455
|
+
const rendered = await renderEntry(entry, request, ctx, options, isr);
|
|
456
|
+
if (key !== undefined && rendered.document !== undefined) memo.set(key, rendered.document);
|
|
457
|
+
return rendered;
|
|
458
|
+
};
|
|
459
|
+
const shared = key === undefined ? await render() : await firstRender.run(key, render);
|
|
460
|
+
// Only a DOCUMENT is shared. A redirect `load` decided was its request's own, so a request
|
|
461
|
+
// that joined one renders for itself rather than answer with another request's `Location`.
|
|
462
|
+
const rendered = shared.document !== undefined || led ? shared : await render();
|
|
462
463
|
// The ETag was only ever a header: a matching `If-None-Match` is a 304, not the page again.
|
|
463
|
-
return
|
|
464
|
+
return rendered.document === undefined
|
|
465
|
+
? rendered.respond()
|
|
466
|
+
: staticResponse(request, rendered.document);
|
|
464
467
|
},
|
|
465
468
|
}));
|
|
466
469
|
}
|
|
470
|
+
|
|
471
|
+
/** A static document a joined request can share, or how to answer when there is none. */
|
|
472
|
+
interface Rendered {
|
|
473
|
+
readonly document?: StaticResult;
|
|
474
|
+
readonly respond: () => Response;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async function renderEntry(
|
|
478
|
+
entry: RouteEntry,
|
|
479
|
+
request: UltimateRequest,
|
|
480
|
+
ctx: RequestContext,
|
|
481
|
+
options: DevRenderOptions,
|
|
482
|
+
isr: IsrController,
|
|
483
|
+
): Promise<Rendered> {
|
|
484
|
+
const data: DevRouteData = { url: request.url.href, params: ctx.params };
|
|
485
|
+
// ONCE per request, before the mode is chosen: every branch of `resultFor` reads this same
|
|
486
|
+
// object, so a route's `load` runs exactly once however its mode splits head from body.
|
|
487
|
+
const loaded = await routeDataFor(entry.config, data);
|
|
488
|
+
// `setRedirect()` inside `load` — the same slot an action's handler fills — answers here,
|
|
489
|
+
// before a document is rendered for a page the visitor is leaving. `withStatus` refuses a
|
|
490
|
+
// 3xx because a rendered document has no `Location`; this is the path that has one.
|
|
491
|
+
const to = takeRedirect(ctx);
|
|
492
|
+
if (to !== undefined) return { respond: () => loadRedirect(entry, to) };
|
|
493
|
+
const result = await resultFor(entry, data, loaded, options, isr, asCtx(ctx));
|
|
494
|
+
if (entry.config.render !== 'static' || typeof result.body !== 'string') {
|
|
495
|
+
return { respond: () => responseOf(result) };
|
|
496
|
+
}
|
|
497
|
+
const document: StaticResult = { ...result, body: result.body };
|
|
498
|
+
return { document, respond: () => responseOf(document) };
|
|
499
|
+
}
|
package/src/runtime-services.ts
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
// boot installs, only backed by embedded drivers.
|
|
5
5
|
|
|
6
6
|
import { mkdirSync } from 'node:fs';
|
|
7
|
-
import { dirname } from 'node:path';
|
|
8
7
|
import { configureAuthLimiters, postgresAuthLimiter, resetAuthLimiters } from '@ultimat3/auth';
|
|
9
8
|
import type { PurgeDriver } from '@ultimat3/cache';
|
|
10
9
|
import { isNoopPurgeDriver, selectPurgeDriver } from '@ultimat3/cache';
|
|
@@ -31,17 +30,18 @@ import type { Transport, TransportSelection } from '@ultimat3/realtime/server';
|
|
|
31
30
|
import { selectTransport } from '@ultimat3/realtime/server';
|
|
32
31
|
import type { Storage } from '@ultimat3/storage';
|
|
33
32
|
import { defineStorage, localDriver, s3Driver, usesDevStorageSecret } from '@ultimat3/storage';
|
|
33
|
+
import { loadAppConfig } from './app-config-load';
|
|
34
34
|
import { LocalDiskUnsafeError, StorageUnwritableError } from './errors';
|
|
35
35
|
import { msg } from './messages';
|
|
36
36
|
import type { DevServices, Env } from './runtime-bindings';
|
|
37
|
-
import {
|
|
38
|
-
import {
|
|
39
|
-
import {
|
|
37
|
+
import { cacheTiersOf, startCacheTiers } from './runtime-cache';
|
|
38
|
+
import { type WorkerConfig, workerConfigOf } from './runtime-jobs';
|
|
39
|
+
import { inboxRetentionOf } from './runtime-notify-retention';
|
|
40
40
|
import type { RuntimeOverrides } from './runtime-overrides';
|
|
41
41
|
import { installRetentionSweep } from './runtime-purge';
|
|
42
|
-
import type { DevDbClient } from './runtime-queue';
|
|
42
|
+
import type { DevDbClient, SchemaMode } from './runtime-queue';
|
|
43
43
|
import { pgExecutorFor, startQueue } from './runtime-queue';
|
|
44
|
-
import {
|
|
44
|
+
import { realtimeConfigOf } from './runtime-realtime';
|
|
45
45
|
|
|
46
46
|
export interface RunningServices {
|
|
47
47
|
readonly services: DevServices;
|
|
@@ -182,7 +182,8 @@ export function startStorage(services: DevServices, env: Env, override?: Storage
|
|
|
182
182
|
bucket,
|
|
183
183
|
endpoint: binding.url,
|
|
184
184
|
...(env['S3_REGION'] === undefined ? {} : { region: env['S3_REGION'] }),
|
|
185
|
-
//
|
|
185
|
+
// A gateway addressed by host and port needs path style; R2 and AWS do not. Declared,
|
|
186
|
+
// never sniffed from the endpoint.
|
|
186
187
|
forcePathStyle: env['S3_FORCE_PATH_STYLE'] === '1',
|
|
187
188
|
}),
|
|
188
189
|
},
|
|
@@ -304,6 +305,8 @@ export async function startServices(
|
|
|
304
305
|
services: DevServices,
|
|
305
306
|
env: Env,
|
|
306
307
|
overrides?: RuntimeOverrides,
|
|
308
|
+
/** `'verify'` for a serving role on an external database — `runtime-queue.ts`'s `SchemaMode`. */
|
|
309
|
+
schema: SchemaMode = 'apply',
|
|
307
310
|
): Promise<RunningServices> {
|
|
308
311
|
// Before the queue: selection is pure — it parses `SMTP_URL` and builds a transport, it does
|
|
309
312
|
// not dial. A typo'd credential must fail on the spot rather than after PGlite has started and
|
|
@@ -317,14 +320,16 @@ export async function startServices(
|
|
|
317
320
|
// service starts, rather than quietly keeping the in-process bus. Which transport, which KV
|
|
318
321
|
// bucket and which presence TTL is `@ultimat3/realtime`'s decision, and it is the same call a
|
|
319
322
|
// `ROLE=sync` container makes, so this process cannot resolve the bus differently from the
|
|
320
|
-
// container it stands in for. The
|
|
321
|
-
|
|
322
|
-
const
|
|
323
|
+
// container it stands in for. The app's config is loaded ONCE, here, and every section below is
|
|
324
|
+
// read out of that one validated object.
|
|
325
|
+
const config = await loadAppConfig(services.root);
|
|
326
|
+
const realtime = realtimeConfigOf(config);
|
|
327
|
+
const workerConfig = workerConfigOf(config);
|
|
323
328
|
const bus: TransportSelection = selectTransport(env, realtime);
|
|
324
329
|
// `env`, not the ambient one: this function is HANDED the boot's environment and every other
|
|
325
330
|
// reader here already uses it, so a queue that asked `process.env` would decide the standby from
|
|
326
331
|
// a different answer than the middleware that routes to it.
|
|
327
|
-
const queue = await startQueue(services, overrides, env);
|
|
332
|
+
const queue = await startQueue(services, overrides, env, schema);
|
|
328
333
|
const { db, jobs, outbox, events } = queue;
|
|
329
334
|
// The same executor the jobs driver, the outbox, the event bus and the idempotency store run
|
|
330
335
|
// on — one pool, one `Bun.sql` that does NOT satisfy `PgExecutor` (`Bun.sql.query` is
|
|
@@ -358,13 +363,14 @@ export async function startServices(
|
|
|
358
363
|
// `x_rate_limit` takes one upsert per request the web role serves, assets included.
|
|
359
364
|
//
|
|
360
365
|
// `inboxRetention` is READ HERE and not defaulted in `runtime-purge.ts`: the two windows are the
|
|
361
|
-
// app's,
|
|
362
|
-
//
|
|
366
|
+
// app's, and a loader that silently answered "never sweep" from inside the sweep would be
|
|
367
|
+
// indistinguishable from an app that chose it.
|
|
363
368
|
started.push(
|
|
364
369
|
installRetentionSweep({
|
|
365
370
|
idempotency: queue.idempotency,
|
|
366
371
|
rateLimit: rateLimitStore,
|
|
367
|
-
|
|
372
|
+
events,
|
|
373
|
+
inboxRetention: inboxRetentionOf(config),
|
|
368
374
|
}),
|
|
369
375
|
);
|
|
370
376
|
// One readiness check per resource this boot OWNS, released with it. Nothing in the tree
|
|
@@ -408,10 +414,8 @@ export async function startServices(
|
|
|
408
414
|
// process that has stopped.
|
|
409
415
|
// The ladder is the app's declaration, never the environment's. `cache.tiers` was declared,
|
|
410
416
|
// validated and documented while NOTHING read it — so an app asking for one rung got four,
|
|
411
|
-
// and one asking for a shared tier got whatever `REDIS_URL` happened to say.
|
|
412
|
-
|
|
413
|
-
// and does not already carry.
|
|
414
|
-
const tiers = await loadCacheTiers(dirname(services.stateDir));
|
|
417
|
+
// and one asking for a shared tier got whatever `REDIS_URL` happened to say.
|
|
418
|
+
const tiers = cacheTiersOf(config);
|
|
415
419
|
started.push(startCacheTiers({ env, purge, transport, tiers }));
|
|
416
420
|
|
|
417
421
|
return {
|
package/src/runtime-storage.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
orgMismatch,
|
|
25
25
|
} from '@ultimat3/storage';
|
|
26
26
|
import { storageUploadRoute } from './runtime-storage-upload';
|
|
27
|
+
import { storedObjectHeaders } from './stored-object-headers';
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
30
|
* The one capability that gates reading a stored object, on every disk. A permission and not a
|
|
@@ -103,11 +104,10 @@ export function authorizeStorageRead(input: StorageReadInput, ctx: RequestContex
|
|
|
103
104
|
*/
|
|
104
105
|
export function assertReadableKey(key: string, actor: Actor): string {
|
|
105
106
|
const safe = assertSafeKey(key);
|
|
106
|
-
// An actor with no org is inside no org, so every tenant-scoped key is somebody else's
|
|
107
|
-
//
|
|
108
|
-
// URL for the actor's missing claim.
|
|
107
|
+
// An actor with no org is inside no org, so every tenant-scoped key is somebody else's —
|
|
108
|
+
// `isWithinOrg` answers exactly that for an empty org, without throwing.
|
|
109
109
|
const orgId = actor.orgId ?? '';
|
|
110
|
-
if (isTenantScoped(safe) &&
|
|
110
|
+
if (isTenantScoped(safe) && !isWithinOrg(safe, orgId)) {
|
|
111
111
|
throw orgMismatch(safe, orgId);
|
|
112
112
|
}
|
|
113
113
|
return safe;
|
|
@@ -185,9 +185,14 @@ export function storageResponse(request: UltimateRequest, read: StorageRead): Re
|
|
|
185
185
|
const headers = new Headers({
|
|
186
186
|
'content-type': read.object.contentType,
|
|
187
187
|
etag,
|
|
188
|
-
'last-modified': read.object.lastModified.toUTCString(),
|
|
189
188
|
'accept-ranges': 'bytes',
|
|
189
|
+
// A type a browser would RUN (html, svg, xml) is a download, never a page on this origin.
|
|
190
|
+
...storedObjectHeaders(read.object.contentType),
|
|
190
191
|
});
|
|
192
|
+
// Omitted, never invented: a disk whose provider reported no date has none, and a `Last-Modified`
|
|
193
|
+
// made up for it is a validator a cache would go on to trust.
|
|
194
|
+
const modified = read.object.lastModified;
|
|
195
|
+
if (modified !== undefined) headers.set('last-modified', modified.toUTCString());
|
|
191
196
|
// Revalidation costs a request and no bytes, which is the trade an authorized response wants:
|
|
192
197
|
// the bytes never change under a key, but the actor's permission to read them can be revoked.
|
|
193
198
|
if (etagMatches(request.header('if-none-match'), etag)) {
|
|
@@ -151,6 +151,7 @@ const overlay = (root: string, app: string): string =>
|
|
|
151
151
|
// name, and `cli/serve` as `packages/cli/serve`. Longest prefix wins, so these are
|
|
152
152
|
// consulted first. `@ultimat3/cli/serve` is what `x new`'s `apps/web/server.ts` imports.
|
|
153
153
|
'@ultimat3/ui/icons/*': [`${root}/packages/ui/src/icons/glyphs/*`],
|
|
154
|
+
'@ultimat3/query/client': [`${root}/packages/query/src/client`],
|
|
154
155
|
'@ultimat3/render/server': [`${root}/packages/render/src/server`],
|
|
155
156
|
'@ultimat3/cli/serve': [`${root}/packages/cli/src/serve-entry`],
|
|
156
157
|
'@ultimat3/testing/test-types': [`${root}/packages/testing/src/test-types`],
|
package/src/schema-diff.ts
CHANGED
|
@@ -40,6 +40,11 @@ export interface SchemaDifference {
|
|
|
40
40
|
|
|
41
41
|
const DECLARED_ONLY = 'is declared by the entities and no migration recorded it';
|
|
42
42
|
const RECORDED_ONLY = 'was recorded by the newest migration and no entity declares it';
|
|
43
|
+
/** The difference's name — what `x db gen "record …"` is told to write down. */
|
|
44
|
+
const REPLICA_IDENTITY_FULL = 'replica identity full';
|
|
45
|
+
const REPLICA_IDENTITY_UNRECORDED =
|
|
46
|
+
'needs REPLICA IDENTITY FULL — a channel with params or a live query subscribes to it — and no ' +
|
|
47
|
+
'migration recorded it';
|
|
43
48
|
|
|
44
49
|
const columns = (names: readonly string[]): string => names.join(', ');
|
|
45
50
|
|
|
@@ -187,6 +192,17 @@ function compareTable(declared: TableDescription, recorded: TableDescription): S
|
|
|
187
192
|
detail: `declares primary key (${columns(declared.primaryKey)}) and the migration recorded (${columns(recorded.primaryKey)})`,
|
|
188
193
|
});
|
|
189
194
|
}
|
|
195
|
+
// One direction: `replica-identity.ts` never reverts the identity, so a recorded FULL nothing
|
|
196
|
+
// needs any more is what `x db gen` leaves behind on purpose, not a difference.
|
|
197
|
+
if (declared.replicaIdentityFull === true && recorded.replicaIdentityFull !== true) {
|
|
198
|
+
out.push({
|
|
199
|
+
direction: 'unmigrated',
|
|
200
|
+
part: 'table',
|
|
201
|
+
table,
|
|
202
|
+
name: REPLICA_IDENTITY_FULL,
|
|
203
|
+
detail: REPLICA_IDENTITY_UNRECORDED,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
190
206
|
out.push(
|
|
191
207
|
...compareList({
|
|
192
208
|
part: 'column',
|