@starci/hfs 4.9.9 → 4.10.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/CHANGELOG.md +33 -0
- package/README.md +20 -8
- package/db/backup-receipt.mjs +10 -47
- package/db/drift.mjs +289 -0
- package/db/dump.mjs +12 -11
- package/db/entity-list.mjs +9 -0
- package/db/migrate.mjs +84 -260
- package/db/new.mjs +46 -0
- package/db/post-cutover.mjs +13 -17
- package/db/postgres.mjs +80 -0
- package/emit/contracts.mjs +5 -9
- package/lint/run.mjs +12 -2
- package/package.json +5 -3
- package/release/gate.mjs +82 -31
- package/release/index.mjs +19 -8
- package/release/push.mjs +4 -4
- package/release/records.mjs +54 -0
- package/release/urgent.mjs +129 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +54 -24
- package/runtime/knowledge/hfs/design-truths.yaml +1 -1
- package/runtime/knowledge/hfs/fe-stack.yaml +4 -11
- package/runtime/knowledge/hfs/rules.yaml +250 -157
- package/runtime/knowledge/hfs/slots.yaml +199 -174
- package/runtime/knowledge/patterns/be/event-bus.yaml +3 -3
- package/runtime/knowledge/patterns/be/idempotency.yaml +3 -3
- package/runtime/knowledge/patterns/be/jobs.yaml +4 -4
- package/runtime/knowledge/patterns/be/projections.yaml +7 -5
- package/runtime/knowledge/patterns/be/queues.yaml +8 -8
- package/runtime/knowledge/patterns/be/reactors.yaml +2 -2
- package/runtime/knowledge/patterns/be/realtime.yaml +6 -6
- package/runtime/knowledge/patterns/be/saga.yaml +3 -3
- package/runtime/knowledge/patterns/be/webhooks.yaml +1 -1
- package/runtime/modules/kernel/command-policy.yaml +1 -1
- package/runtime/modules/kernel/failure-codes.yaml +86 -115
- package/runtime/modules/schemas/application-stacks.schema.yaml +5 -5
- package/runtime/scripts/hfs/app-wiring.mjs +23 -2
- package/runtime/scripts/hfs/architecture/canon-clones.mjs +1 -1
- package/runtime/scripts/hfs/architecture/{supabase-be.mjs → identity.mjs} +45 -15
- package/runtime/scripts/hfs/architecture/index.mjs +2 -3
- package/runtime/scripts/hfs/architecture/{supabase-ast.mjs → jwt-ast.mjs} +2 -103
- package/runtime/scripts/hfs/architecture/register-once.mjs +1 -1
- package/runtime/scripts/hfs/architecture/schema-owner.mjs +29 -4
- package/runtime/scripts/hfs/architecture/server-action.mjs +3 -4
- package/runtime/scripts/hfs/architecture/side-boundary.mjs +1 -1
- package/runtime/scripts/hfs/architecture/{supabase-keys.mjs → sql-keys.mjs} +1 -1
- package/runtime/scripts/hfs/architecture/sql-owner.mjs +25 -27
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +7 -3
- package/runtime/scripts/hfs/check.mjs +8 -8
- package/runtime/scripts/hfs/coverage-scope.mjs +1 -1
- package/runtime/scripts/hfs/db-baseline-verb.mjs +48 -30
- package/runtime/scripts/hfs/db-plan-verb.mjs +17 -106
- package/runtime/scripts/hfs/db-restamp-verb.mjs +11 -10
- package/runtime/scripts/hfs/declaration-shape.mjs +15 -18
- package/runtime/scripts/hfs/manifest-shape.mjs +6 -5
- package/runtime/scripts/hfs/rule-params-shape.mjs +4 -1
- package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
- package/runtime/scripts/hfs/rules/cli.mjs +1 -1
- package/runtime/scripts/hfs/rules/database-access.mjs +8 -74
- package/runtime/scripts/hfs/rules/database-baseline.mjs +3 -3
- package/runtime/scripts/hfs/rules/database-config.mjs +6 -214
- package/runtime/scripts/hfs/rules/database-constants.mjs +8 -10
- package/runtime/scripts/hfs/rules/database-migrations.mjs +3 -3
- package/runtime/scripts/hfs/rules/database-queues.mjs +4 -5
- package/runtime/scripts/hfs/rules/database-sql.mjs +1 -1
- package/runtime/scripts/hfs/rules/database.mjs +36 -39
- package/runtime/scripts/hfs/rules/edition.mjs +1 -1
- package/runtime/scripts/hfs/rules/{supabase-secrets.mjs → lite-secrets.mjs} +6 -6
- package/runtime/scripts/hfs/rules/script-hygiene.mjs +1 -10
- package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +5 -4
- package/runtime/scripts/hfs/rules/stacks-release.mjs +3 -2
- package/runtime/scripts/hfs/rules/stacks-runtime.mjs +2 -2
- package/runtime/scripts/hfs/rules/stacks.mjs +1 -1
- package/runtime/scripts/hfs/slot-app-view.mjs +1 -1
- package/runtime/scripts/hfs/slots.mjs +8 -2
- package/runtime/scripts/hfs/sql/pg-parse.mjs +2 -2
- package/runtime/scripts/lib/secret-patterns.mjs +2 -2
- package/runtime/scripts/lib/swarm-stack.mjs +7 -4
- package/scaffold/add-app.mjs +2 -10
- package/scaffold/add-cli-lite.mjs +6 -6
- package/scaffold/add-compose.mjs +1 -1
- package/scaffold/add-inbox-lite.mjs +4 -8
- package/scaffold/add-plan.mjs +1 -1
- package/scaffold/add-queue-lite.mjs +16 -24
- package/scaffold/add-table.mjs +34 -26
- package/scaffold/add.mjs +2 -2
- package/scaffold/app.mjs +19 -86
- package/scaffold/lite-exports.mjs +1 -1
- package/src/main.mjs +24 -22
- package/stack/cli.mjs +10 -2
- package/stack/deploy.mjs +21 -4
- package/stack/target-binding.mjs +0 -60
- package/stack/urgent-floor.mjs +147 -0
- package/sync/ci-commands.mjs +3 -1
- package/sync/index.mjs +32 -8
- package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +4 -1
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +1 -17
- package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +6 -8
- package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +4 -1
- package/templates/app/codegen-lite/scripts/codegen.mjs +2 -39
- package/templates/app/db-bootstrap/db/bootstrap.sql +139 -0
- package/templates/app/gitignore-lite +0 -3
- package/templates/app/package-scripts/db.json +9 -0
- package/templates/app/package-scripts/package.json +0 -1
- package/templates/app/package-scripts-lite/package.json +1 -1
- package/templates/app/skeleton/.starcistacks/dev/README.md +1 -1
- package/templates/app/skeleton/.starcistacks/production/README.md +1 -1
- package/templates/app/skeleton/README.md +1 -1
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +84 -19
- package/templates/app/skeleton-lite/.starcistacks/dev/README.md +5 -5
- package/templates/app/skeleton-lite/.starcistacks/dev/environment.json +1 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/recovery/restore-proof.md +1 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +69 -6
- package/templates/app/skeleton-lite/.starcistacks/dev/runtime/env/KEYS.md +18 -7
- package/templates/app/skeleton-lite/.starcistacks/production/README.md +6 -6
- package/templates/app/skeleton-lite/.starcistacks/production/environment.json +1 -1
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/recovery/restore-proof.md +2 -2
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +69 -6
- package/templates/app/skeleton-lite/.starcistacks/production/runtime/env/KEYS.md +17 -9
- package/templates/app/skeleton-lite/README.md +11 -11
- package/templates/app/skeleton-lite/{supabase → db}/migrations/__timestamp___baseline.sql +21 -1
- package/templates/app/tool-config-lite/prettierignore +0 -1
- package/templates/be/inbox/database-client.spec.ts.tpl +9 -5
- package/templates/be/inbox/database-client.ts.tpl +13 -4
- package/templates/be/inbox/platform/client-operation-outcomes.sql.tpl +47 -0
- package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +3 -1
- package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +2 -2
- package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +6 -1
- package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +39 -7
- package/templates/be/inbox/platform/migration.sql.tpl +22 -0
- package/templates/be/inbox/service.spec.ts.tpl +2 -2
- package/templates/be/inbox/service.ts.tpl +1 -2
- package/templates/be/patterns/jobs/platform-lite/job-claim.service.spec.ts.tpl +156 -0
- package/templates/be/patterns/jobs/platform-lite/job-claim.service.ts.tpl +114 -0
- package/templates/be/patterns/jobs/platform-lite/jobs.sql.tpl +51 -0
- package/templates/be/patterns/queues/platform-lite/queue-outbox.service.spec.ts.tpl +62 -0
- package/templates/be/patterns/queues/platform-lite/queue-outbox.service.ts.tpl +20 -0
- package/templates/be/patterns/queues/platform-lite/queue-outbox.sql.tpl +35 -0
- package/templates/be/patterns/queues/platform-lite/queue-relay.service.spec.ts.tpl +4 -1
- package/templates/be/patterns/queues/platform-lite/queue-relay.service.ts.tpl +16 -5
- package/templates/be/patterns/realtime/channel.gateway.spec.ts.tpl +8 -8
- package/templates/be/patterns/realtime/channel.websocket.module.ts.tpl +3 -3
- package/templates/be/patterns/realtime/channel.websocket.options.ts.tpl +2 -2
- package/templates/be/skeleton/src/modules/domain/identity/identity.decorators.ts +18 -7
- package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +3 -0
- package/templates/be/skeleton/src/modules/platform/composition/index.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/errors/errors.filter.spec.ts +15 -1
- package/templates/be/skeleton/src/modules/platform/errors/errors.filter.ts +10 -9
- package/templates/be/skeleton/src/modules/platform/http-security/index.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.spec.ts +34 -18
- package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.ts +29 -8
- package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.spec.ts +80 -4
- package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +39 -11
- package/templates/be/skeleton-lite/apps/api/Dockerfile +0 -1
- package/templates/be/skeleton-lite/apps/api/src/api.env.ts +9 -5
- package/templates/be/skeleton-lite/apps/api/src/api.options.ts +7 -4
- package/templates/be/skeleton-lite/apps/api/src/app.module.ts +4 -4
- package/templates/be/skeleton-lite/apps/api/src/compose/domain.ts +3 -3
- package/templates/be/skeleton-lite/apps/api/src/compose/integrations.ts +9 -13
- package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +4 -2
- package/templates/be/skeleton-lite/apps/cli/src/cli.env.ts +2 -2
- package/templates/be/skeleton-lite/apps/cli/src/compose/platform.ts +3 -2
- package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.spec.ts +4 -4
- package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.ts +3 -3
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.contracts.ts +1 -1
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +23 -13
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.options.ts +3 -3
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/index.ts +4 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.spec.ts +22 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.ts +25 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.config.ts +12 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.decorators.ts +9 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.options.ts +35 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/errors/passport.error.ts +22 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/index.ts +12 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.config.ts +23 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.cookie.ts +29 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.decorators.ts +22 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.options.ts +63 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.password.ts +11 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.spec.ts +32 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.ts +69 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/google.strategy.ts +37 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/local.strategy.ts +36 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/oauth-state.store.ts +59 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/database.functions.ts +24 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/database.module.ts +2 -1
- package/templates/be/skeleton-lite/src/modules/platform/database/database.options.ts +4 -2
- package/templates/be/skeleton-lite/src/modules/platform/database/database.sql.ts +19 -6
- package/templates/be/skeleton-lite/src/modules/platform/database/entities.ts +6 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/migration-runner.service.ts +1 -1
- package/templates/be/skeleton-lite/src/modules/platform/database/primary.config.ts +2 -2
- package/templates/be/skeleton-lite/src/modules/platform/database/seed-runner.service.ts +2 -2
- package/templates/be/skeleton-lite/src/modules/platform/http-security/index.ts +1 -1
- package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +18 -5
- package/templates/be/skeleton-lite/src/tests/fixtures/builders/cli.builder.ts +1 -1
- package/templates/be/table/entities.index.ts.tpl +4 -0
- package/templates/be/table/entity.ts.tpl +21 -0
- package/templates/be/table/index.ts.tpl +1 -0
- package/templates/be/table/module.ts.tpl +1 -1
- package/templates/be/table/service.api.ts.tpl +10 -11
- package/templates/be/table/service.ts.tpl +5 -6
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/auth-door.ts +30 -0
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +46 -14
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/token-store.ts +31 -0
- package/templates/fe/skeleton-lite/apps/web/src/modules/config/index.ts +2 -5
- package/db/local-migrate.mjs +0 -194
- package/db/reset.mjs +0 -125
- package/emit/db-types.mjs +0 -271
- package/runtime/scripts/api/supabase/database-dump.mjs +0 -5
- package/runtime/scripts/api/supabase/database-push.mjs +0 -5
- package/runtime/scripts/api/supabase/database-query.mjs +0 -5
- package/runtime/scripts/api/supabase/lib.mjs +0 -55
- package/runtime/scripts/api/supabase/migration-repair.mjs +0 -5
- package/runtime/scripts/hfs/architecture/supabase-tables.mjs +0 -50
- package/runtime/scripts/hfs/architecture/supabase.mjs +0 -226
- package/scaffold/supabase-ports.mjs +0 -26
- package/templates/app/package-scripts/supabase.json +0 -7
- package/templates/app/skeleton-lite/supabase/config.toml +0 -48
- package/templates/be/inbox/platform/event-bus/persistence/event-bus.sql.ts.tpl +0 -5
- package/templates/be/inbox/platform/inbox/persistence/inbox.rows.ts.tpl +0 -33
- package/templates/be/inbox/platform/inbox/persistence/inbox.sql.ts.tpl +0 -33
- package/templates/be/inbox/sql.ts.tpl +0 -16
- package/templates/be/patterns/jobs/platform-lite/persistence.jobs.sql.ts.tpl +0 -33
- package/templates/be/patterns/queues/platform-lite/persistence.queue.sql.ts.tpl +0 -12
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/errors/supabase.error.ts +0 -16
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/index.ts +0 -6
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.spec.ts +0 -27
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.ts +0 -24
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.config.ts +0 -8
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.decorators.ts +0 -17
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.jwks.ts +0 -32
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.options.ts +0 -24
- package/templates/be/table/rows.ts.tpl +0 -4
- package/templates/be/table/sql.api.ts.tpl +0 -7
- package/templates/be/table/sql.ts.tpl +0 -4
- package/templates/fe/auth/supabase-client.ts.tpl +0 -38
- package/templates/fe/skeleton/apps/__app__/src/app/auth/callback/route.ts +0 -11
- package/templates/fe/skeleton/apps/__app__/src/modules/auth/pkce-client.ts +0 -18
- package/templates/fe/skeleton/apps/__app__/src/modules/auth/server.ts +0 -19
- /package/templates/app/skeleton-lite/{supabase → db}/seed.sql +0 -0
package/db/migrate.mjs
CHANGED
|
@@ -1,43 +1,37 @@
|
|
|
1
|
-
// migrate.mjs - `starci app db migrate`: puts a database's migration history in the state the app's repository declares,
|
|
1
|
+
// migrate.mjs - `starci app db migrate`: puts a self-hosted database's migration history in the state the app's repository declares,
|
|
2
|
+
// idempotently. The database is the connection URL in the environment variable `--url-env` names (default DATABASE_URL); psql
|
|
3
|
+
// applies each migration and records it in the history table in ONE transaction (db/postgres.mjs).
|
|
2
4
|
// The optional `database:` block of .starcistacks/application-stacks.yaml names the baseline migration, the optional second baseline (standard),
|
|
3
|
-
// the held-back migrations (applied later by `db post-cutover`), the restamp pairs and the archive directory. The plan, from the
|
|
4
|
-
// repair revert each restamped old version still
|
|
5
|
-
// baseline revert the archived rows, mark the baseline applied (it is NOT run)
|
|
6
|
-
// forward
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// environment or the sealed custody, PGPASSWORD split off a --url-env connection string), never in argv, a file, a line or an error.
|
|
5
|
+
// the held-back migrations (applied later by `db post-cutover`), the restamp pairs and the archive directory. The plan, from the history:
|
|
6
|
+
// repair revert each restamped old version still recorded and mark its new version applied
|
|
7
|
+
// baseline revert the archived rows, mark the baseline applied (it is NOT run) when the history predates it
|
|
8
|
+
// forward apply every other migration in stamp order, EXCEPT the held-back ones
|
|
9
|
+
// With no `database:` block every active migration is forward. When the history already is baseline + forward it prints
|
|
10
|
+
// `nothing to do` and makes no mutating call.
|
|
10
11
|
import fs from 'node:fs';
|
|
11
|
-
import os from 'node:os';
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import { isPlainObject as plain } from '../runtime/engine/plain-object.mjs';
|
|
14
14
|
import { byCodeUnit } from '../runtime/engine/by-code-unit.mjs';
|
|
15
|
-
import {
|
|
16
|
-
import { STACKS_DIR } from '../stack/config.mjs';
|
|
17
|
-
import { firstLine, tool } from '../stack/context.mjs';
|
|
15
|
+
import { MIGRATIONS_DIR } from '../runtime/scripts/hfs/rules/database-constants.mjs';
|
|
18
16
|
import { approvalMissing, failure, refusal } from '../stack/errors.mjs';
|
|
19
17
|
import { readDeclaration, runVerb } from '../release/index.mjs';
|
|
20
|
-
import {
|
|
21
|
-
import { parseTargetBinding, assertUrlDatabase, assertBoundCustody, boundSupabaseLauncher, boundLinkedDatabaseUrl, nativeDatabaseEnvironment } from '../stack/target-binding.mjs';
|
|
18
|
+
import { DEFAULT_DB_URL_ENV, HISTORY_DDL, dbTarget, historyVersions, markApplied, markReverted, psqlTransaction } from './postgres.mjs';
|
|
22
19
|
|
|
23
20
|
const DATABASE_CODE = 'DB_CONFIG_INVALID';
|
|
21
|
+
/** The managed pre-migration bootstrap (slot app.db.bootstrap), applied before the first migration of an empty database. */
|
|
22
|
+
export const BOOTSTRAP_FILE = 'db/bootstrap.sql';
|
|
24
23
|
|
|
25
24
|
const SEGMENT = /^[A-Za-z0-9._-]+$/u;
|
|
26
25
|
const NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*$/u;
|
|
27
26
|
const IDENT = /^[a-z][a-z0-9_]*$/u;
|
|
28
|
-
const SLUG = /^[a-z][a-z0-9-]*$/u;
|
|
29
|
-
const KEY = /^[A-Z][A-Z0-9_]*$/u;
|
|
30
27
|
const STAMP = /^\d{14}$/u;
|
|
31
|
-
const PROJECT_REF = /^[a-z0-9]{10,40}$/u;
|
|
32
28
|
const SIGNATURE = /^[A-Za-z0-9_."(), ]+$/u;
|
|
33
29
|
const MIGRATION = /^(\d{14})_([A-Za-z0-9_-]+)\.sql$/u;
|
|
34
|
-
const PENDING = /(\d{14})_[^\s"]+?\.sql/gu;
|
|
35
30
|
|
|
36
31
|
const matches = (pattern) => (value) => typeof value === 'string' && pattern.test(value);
|
|
37
32
|
const listOf = (item) => (value) => Array.isArray(value) && value.every((entry) => item(entry));
|
|
38
33
|
const dirOk = (value) => typeof value === 'string' && value.split('/').every((part) => SEGMENT.test(part) && part !== '..');
|
|
39
34
|
const pairOk = (value) => plain(value) && Object.keys(value).length === 2 && matches(STAMP)(value.from) && matches(STAMP)(value.to);
|
|
40
|
-
const passwordOk = (value) => plain(value) && Object.keys(value).length === 3 && matches(SLUG)(value.env) && matches(SLUG)(value.member) && matches(KEY)(value.key);
|
|
41
35
|
const POST_FIELDS = ['schema', 'functionPrefixes', 'functionNames', 'functions', 'minAgeHours'];
|
|
42
36
|
const POST_CHECKS = {
|
|
43
37
|
schema: matches(IDENT), functionPrefixes: listOf(matches(IDENT)), functionNames: listOf(matches(IDENT)),
|
|
@@ -45,22 +39,19 @@ const POST_CHECKS = {
|
|
|
45
39
|
};
|
|
46
40
|
const postOk = (value) => plain(value) && Object.keys(value).every((key) => POST_FIELDS.includes(key) && POST_CHECKS[key](value[key]));
|
|
47
41
|
|
|
48
|
-
const resetOk = (value) => plain(value) && Object.keys(value).every((key) => key === 'backupMaxAgeHours' && Number.isFinite(value[key]) && value[key] > 0);
|
|
49
|
-
|
|
50
42
|
const CHECKS = {
|
|
51
43
|
migrationsDir: [dirOk, 'must be a relative directory inside the app'],
|
|
52
44
|
archiveDir: [dirOk, 'must be a relative directory inside the app'],
|
|
53
45
|
baseline: [matches(NAME), `must match ${NAME} (the name after the stamp)`],
|
|
54
46
|
standard: [matches(NAME), `must match ${NAME} (the name after the stamp)`],
|
|
55
47
|
heldBack: [listOf(matches(NAME)), 'must be a list of migration names'],
|
|
56
|
-
projectRef: [matches(PROJECT_REF), `must match ${PROJECT_REF}`],
|
|
57
48
|
restamp: [listOf(pairOk), 'must be a list of { from, to } fourteen-digit stamps'],
|
|
58
|
-
password: [passwordOk, 'must be { env, member, key } naming a sealed value'],
|
|
59
49
|
postCutover: [postOk, `must use only ${POST_FIELDS.join(', ')} with valid values`],
|
|
60
|
-
reset: [resetOk, 'must be a mapping with only optional finite positive backupMaxAgeHours'],
|
|
61
50
|
};
|
|
62
51
|
|
|
63
|
-
|
|
52
|
+
const DEFAULTS = Object.freeze({ migrationsDir: MIGRATIONS_DIR, archiveDir: null, baseline: null, standard: null, heldBack: [], restamp: [], postCutover: null });
|
|
53
|
+
|
|
54
|
+
/** Judges the `database:` block. Returns { config } or { problems }; every field is optional (`standard` needs `baseline`). */
|
|
64
55
|
function normalizeDatabase(block) {
|
|
65
56
|
if (!plain(block)) return { problems: ['database: must be a mapping'] };
|
|
66
57
|
const problems = [];
|
|
@@ -68,18 +59,15 @@ function normalizeDatabase(block) {
|
|
|
68
59
|
if (!Object.hasOwn(CHECKS, key)) problems.push(`database.${key}: is not a field of the database block`);
|
|
69
60
|
else if (!CHECKS[key][0](value)) problems.push(`database.${key}: ${CHECKS[key][1]}`);
|
|
70
61
|
}
|
|
71
|
-
if (block.baseline === undefined) problems.push('database.
|
|
62
|
+
if (block.standard !== undefined && block.baseline === undefined) problems.push('database.standard: needs database.baseline');
|
|
72
63
|
if (problems.length > 0) return { problems };
|
|
73
|
-
|
|
74
|
-
migrationsDir: 'supabase/migrations', archiveDir: null, standard: null, heldBack: [], projectRef: null, restamp: [], password: null, postCutover: null, reset: null, ...block,
|
|
75
|
-
};
|
|
76
|
-
return { config };
|
|
64
|
+
return { config: { ...DEFAULTS, ...block } };
|
|
77
65
|
}
|
|
78
66
|
|
|
79
|
-
/** The normalised `database:` block of the app at `root
|
|
67
|
+
/** The normalised `database:` block of the app at `root` (all defaults when absent); an invalid block is a refusal. */
|
|
80
68
|
export function loadDatabase(root) {
|
|
81
69
|
const block = readDeclaration(root).database;
|
|
82
|
-
if (block === undefined)
|
|
70
|
+
if (block === undefined) return { ...DEFAULTS };
|
|
83
71
|
const { config, problems } = normalizeDatabase(block);
|
|
84
72
|
if (!config) throw refusal(DATABASE_CODE, `${problems.length} problem(s) in database: ${problems.slice(0, 6).join('; ')}`);
|
|
85
73
|
return config;
|
|
@@ -92,13 +80,14 @@ const sqlNames = (dir, what) => {
|
|
|
92
80
|
};
|
|
93
81
|
|
|
94
82
|
const stampOf = (file) => file.split('_')[0];
|
|
83
|
+
const nameOf = (file) => file.slice(file.indexOf('_') + 1, -'.sql'.length);
|
|
95
84
|
|
|
96
85
|
function archiveOf(root, decl) {
|
|
97
86
|
if (decl.archiveDir === null || !fs.existsSync(path.join(root, decl.archiveDir))) return [];
|
|
98
87
|
const moved = new Map(decl.restamp.map((pair) => [pair.from, pair.to]));
|
|
99
88
|
return sqlNames(path.join(root, decl.archiveDir), 'the archive directory').map((file) => {
|
|
100
89
|
const version = moved.get(stampOf(file)) ?? stampOf(file);
|
|
101
|
-
return {
|
|
90
|
+
return { name: `${version}_${file.slice(file.indexOf('_') + 1)}`, version };
|
|
102
91
|
});
|
|
103
92
|
}
|
|
104
93
|
|
|
@@ -106,7 +95,7 @@ function stampProblems(model, decl) {
|
|
|
106
95
|
const { files, baselineFile, standardFile, baseline, standard } = model;
|
|
107
96
|
for (const file of files) {
|
|
108
97
|
const version = stampOf(file);
|
|
109
|
-
if (file !== baselineFile && version <= baseline) throw refusal('DB_STAMP_ORDER', `${file} sorts at or before the baseline: it would run before the baseline on a fresh database`);
|
|
98
|
+
if (baselineFile && file !== baselineFile && version <= baseline) throw refusal('DB_STAMP_ORDER', `${file} sorts at or before the baseline: it would run before the baseline on a fresh database`);
|
|
110
99
|
if (standardFile && file !== baselineFile && file !== standardFile && version <= standard) throw refusal('DB_STAMP_ORDER', `${file} sorts before the second baseline`);
|
|
111
100
|
if (decl.restamp.some((pair) => pair.to === version)) throw refusal('DB_STAMP_COLLISION', `${file} collides with a restamp target`);
|
|
112
101
|
}
|
|
@@ -118,9 +107,9 @@ function checkedFiles(root, decl) {
|
|
|
118
107
|
const bad = files.filter((file) => !MIGRATION.test(file));
|
|
119
108
|
if (bad.length > 0) throw refusal('DB_MIGRATION_NAME', `migration files must be <14-digit stamp>_<name>.sql (got ${bad[0]})`);
|
|
120
109
|
if (new Set(files.map(stampOf)).size !== files.length) throw refusal('DB_STAMP_DUPLICATE', 'two migrations share one stamp');
|
|
121
|
-
const baselineFile = files.find((file) => file.endsWith(`_${decl.baseline}.sql`));
|
|
110
|
+
const baselineFile = decl.baseline === null ? undefined : files.find((file) => file.endsWith(`_${decl.baseline}.sql`));
|
|
122
111
|
const standardFile = decl.standard === null ? undefined : files.find((file) => file.endsWith(`_${decl.standard}.sql`));
|
|
123
|
-
if (!baselineFile) throw refusal('DB_BASELINE_MISSING', `no migration named ${decl.baseline} in ${decl.migrationsDir}`);
|
|
112
|
+
if (decl.baseline !== null && !baselineFile) throw refusal('DB_BASELINE_MISSING', `no migration named ${decl.baseline} in ${decl.migrationsDir}`);
|
|
124
113
|
if (decl.standard !== null && !standardFile) throw refusal('DB_BASELINE_MISSING', `no migration named ${decl.standard} in ${decl.migrationsDir}`);
|
|
125
114
|
return { files, baselineFile, standardFile };
|
|
126
115
|
}
|
|
@@ -130,178 +119,36 @@ export function buildModel(root, decl) {
|
|
|
130
119
|
const { files, baselineFile, standardFile } = checkedFiles(root, decl);
|
|
131
120
|
const held = files.filter((file) => decl.heldBack.some((name) => file.endsWith(`_${name}.sql`)));
|
|
132
121
|
const push = files.filter((file) => !held.includes(file));
|
|
133
|
-
const baseline = stampOf(baselineFile);
|
|
122
|
+
const baseline = baselineFile ? stampOf(baselineFile) : null;
|
|
134
123
|
const model = {
|
|
135
124
|
root, decl, files, baselineFile, standardFile, baseline, standard: standardFile ? stampOf(standardFile) : null, held, push,
|
|
136
125
|
heldVersions: held.map(stampOf), pushPending: push.filter((file) => file !== baselineFile).map(stampOf), archive: archiveOf(root, decl),
|
|
137
126
|
};
|
|
138
127
|
stampProblems(model, decl);
|
|
139
|
-
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/* ---- reading the CLI */
|
|
143
|
-
|
|
144
|
-
/** Versions from the JSON form of `supabase migration list`, or null when the text holds no readable JSON. */
|
|
145
|
-
function jsonVersions(text) {
|
|
146
|
-
const start = text.indexOf('{"migrations"');
|
|
147
|
-
if (start < 0) return null;
|
|
148
|
-
const end = text.indexOf('\n', start);
|
|
149
|
-
try {
|
|
150
|
-
const items = JSON.parse(text.slice(start, end > 0 ? end : undefined)).migrations;
|
|
151
|
-
return { remote: new Set(items.filter((item) => item.remote).map((item) => item.remote)), local: new Set(items.filter((item) => item.local).map((item) => item.local)) };
|
|
152
|
-
} catch { return null; }
|
|
128
|
+
const base = baseline === null ? [] : [baseline];
|
|
129
|
+
return { ...model, pushVersions: new Set([...base, ...model.pushPending]), allowed: new Set([...base, ...model.pushPending, ...model.heldVersions]) };
|
|
153
130
|
}
|
|
154
131
|
|
|
155
|
-
/** Versions from the older table form: rows of `local | remote | time`. */
|
|
156
|
-
function tableVersions(text) {
|
|
157
|
-
const remote = new Set();
|
|
158
|
-
const local = new Set();
|
|
159
|
-
for (const cells of text.split('\n').map((line) => line.split('|').map((cell) => cell.trim())).filter((row) => row.length >= 3)) {
|
|
160
|
-
if (STAMP.test(cells[0])) local.add(cells[0]);
|
|
161
|
-
if (STAMP.test(cells[1])) remote.add(cells[1]);
|
|
162
|
-
}
|
|
163
|
-
return { remote, local };
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/** Remote (and local) versions from `supabase migration list`: the JSON form, or the older table. */
|
|
167
|
-
const parseMigrationList = (text) => jsonVersions(text) ?? tableVersions(text);
|
|
168
|
-
|
|
169
|
-
/** The versions a `db push --dry-run` would apply. */
|
|
170
|
-
export const pendingVersions = (text) => [...new Set([...text.matchAll(PENDING)].map((match) => match[1]))];
|
|
171
|
-
|
|
172
132
|
/* ---- the target */
|
|
173
133
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
function parseConnection(value) {
|
|
177
|
-
try {
|
|
178
|
-
const url = new URL(value);
|
|
179
|
-
if (!/^postgres(?:ql)?:$/u.test(url.protocol)) return null;
|
|
180
|
-
const password = decodeURIComponent(url.password);
|
|
181
|
-
url.password = '';
|
|
182
|
-
return { url: url.toString(), password };
|
|
183
|
-
} catch { return null; }
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function urlTarget(ctx, name, binding = null) {
|
|
187
|
-
if (!KEY.test(name)) throw refusal('DB_USAGE', '--url-env must name an UPPER_CASE environment variable');
|
|
188
|
-
const value = ctx.env[name];
|
|
189
|
-
if (typeof value !== 'string' || value === '') throw refusal('DB_URL_ENV_MISSING', `${name} is not set`);
|
|
190
|
-
if (binding && (binding.mode !== 'url-env' || name !== binding.urlEnv)) throw refusal('DB_WRONG_PROJECT', 'the URL selector differs from the accepted native target');
|
|
191
|
-
assertUrlDatabase(binding, value);
|
|
192
|
-
const parsed = parseConnection(value);
|
|
193
|
-
if (!parsed) throw refusal('DB_URL_INVALID', `${name} is not a postgres connection URL`);
|
|
194
|
-
const secrets = [value, parsed.password].filter(Boolean);
|
|
195
|
-
return { kind: 'url', how: `connection string from ${name}`, url: parsed.url, env: parsed.password ? { PGPASSWORD: parsed.password } : {}, secrets, nativeTarget: binding, privateInputEnv: name };
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function sealedPassword(ctx, spec, binding = null) {
|
|
199
|
-
if (binding) assertBoundCustody(ctx.cwd, path.join(ctx.cwd, STACKS_DIR, spec.env, 'secrets', `${spec.member}.enc`));
|
|
200
|
-
let map;
|
|
201
|
-
try { map = readMap(path.join(ctx.cwd, STACKS_DIR, spec.env, 'secrets', `${spec.member}.enc`), ctx.sops); } catch { throw refusal('DB_PASSWORD_UNREADABLE', `the sealed member ${spec.member} cannot be opened`); }
|
|
202
|
-
if (typeof map[spec.key] !== 'string' || map[spec.key] === '') throw refusal('DB_PASSWORD_UNREADABLE', `the sealed member ${spec.member} has no value for ${spec.key}`);
|
|
203
|
-
return map[spec.key];
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/** The database password for the linked CLI: SUPABASE_DB_PASSWORD from the environment, else the declared sealed value, else none (the CLI mints a login). */
|
|
207
|
-
function linkedPassword(ctx, decl, binding = null) {
|
|
208
|
-
if (binding) {
|
|
209
|
-
if (!decl.password || decl.password.env !== binding.environment || ctx.env.SUPABASE_DB_PASSWORD !== undefined)
|
|
210
|
-
throw refusal('DB_PASSWORD_UNREADABLE', 'the bound linked target requires its declared sealed password without an ambient override');
|
|
211
|
-
return sealedPassword(ctx, decl.password, binding);
|
|
212
|
-
}
|
|
213
|
-
const given = ctx.env.SUPABASE_DB_PASSWORD;
|
|
214
|
-
if (typeof given === 'string' && given !== '') return given;
|
|
215
|
-
return decl.password === null ? null : sealedPassword(ctx, decl.password);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
function linkedTarget(ctx, decl, binding = null) {
|
|
219
|
-
if (binding && (binding.mode !== 'linked' || decl.projectRef !== binding.projectRef || !decl.password || decl.password.env !== binding.environment))
|
|
220
|
-
throw refusal('DB_WRONG_PROJECT', 'the linked project and sealed password must match the accepted native target');
|
|
221
|
-
let ref;
|
|
222
|
-
if (binding) assertBoundCustody(ctx.cwd, path.join(ctx.cwd, 'supabase', '.temp', 'project-ref'));
|
|
223
|
-
try { ref = fs.readFileSync(path.join(ctx.cwd, 'supabase', '.temp', 'project-ref'), 'utf8').trim(); } catch { throw refusal('DB_NOT_LINKED', 'no linked project (supabase/.temp/project-ref): run supabase link first'); }
|
|
224
|
-
if (decl.projectRef !== null && ref !== decl.projectRef) throw refusal('DB_WRONG_PROJECT', `the app is linked to ${ref}, not the declared project ${decl.projectRef}`);
|
|
225
|
-
if (binding && ref !== binding.projectRef) throw refusal('DB_WRONG_PROJECT', 'the linked project differs from the accepted native target');
|
|
226
|
-
let nativeUrl;
|
|
227
|
-
if (binding) {
|
|
228
|
-
const saved = path.join(ctx.cwd, 'supabase', '.temp', 'pooler-url');
|
|
229
|
-
const stat = fs.lstatSync(saved, { throwIfNoEntry: false });
|
|
230
|
-
if (stat) {
|
|
231
|
-
assertBoundCustody(ctx.cwd, saved);
|
|
232
|
-
if (stat.size > 8192) throw refusal('DB_WRONG_PROJECT', 'the saved native pooler endpoint exceeds its public bound');
|
|
233
|
-
}
|
|
234
|
-
nativeUrl = boundLinkedDatabaseUrl(binding, stat ? fs.readFileSync(saved, 'utf8') : null);
|
|
235
|
-
}
|
|
236
|
-
const password = linkedPassword(ctx, decl, binding);
|
|
237
|
-
if (binding) return { kind: 'url', logicalKind: 'linked', how: `linked project ${ref}`, ref, url: nativeUrl,
|
|
238
|
-
env: { PGPASSWORD: password }, secrets: [password], nativeTarget: binding };
|
|
239
|
-
return { kind: 'linked', how: `linked project ${ref}`, ref, url: null, env: password ? { SUPABASE_DB_PASSWORD: password } : {}, secrets: password ? [password] : [], nativeTarget: binding };
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/** { kind, how, env, secrets, url }: the linked project of the app (default) or the connection string in the named environment variable. */
|
|
243
|
-
export function resolveTarget(ctx, decl, options) {
|
|
244
|
-
if (options.linked === true && options.urlEnv !== undefined) throw refusal('DB_USAGE', '--linked and --url-env exclude each other');
|
|
245
|
-
return options.urlEnv === undefined ? linkedTarget(ctx, decl, options.nativeTarget) : urlTarget(ctx, options.urlEnv, options.nativeTarget);
|
|
246
|
-
}
|
|
134
|
+
/** The database of `--url-env` (default DATABASE_URL). */
|
|
135
|
+
export const resolveTarget = (ctx, options = {}) => dbTarget(ctx, options.urlEnv ?? DEFAULT_DB_URL_ENV);
|
|
247
136
|
|
|
248
|
-
/**
|
|
249
|
-
export const
|
|
137
|
+
/** True for a database on this machine's loopback (the local db service): applying to it needs no arming. */
|
|
138
|
+
export const isLoopback = (url) => ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(url).hostname);
|
|
250
139
|
|
|
251
140
|
/** Refuses a mutation unless the operator armed it with STARCI_DB_ARMED=1. */
|
|
252
141
|
export function requireArmed(ctx) {
|
|
253
142
|
if (ctx.env.STARCI_DB_ARMED !== '1') throw approvalMissing('DB_NOT_ARMED', 'set STARCI_DB_ARMED=1 to apply database changes');
|
|
254
143
|
}
|
|
255
144
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
const script = path.join(root, 'node_modules', 'supabase', 'dist', 'supabase.js');
|
|
259
|
-
return fs.existsSync(script) ? [process.execPath, script] : ['supabase'];
|
|
260
|
-
};
|
|
261
|
-
|
|
262
|
-
/** Runs the Supabase CLI through the seam; a failure names the first stderr line with every secret scrubbed. Returns { stdout, text } (text = stdout plus stderr). */
|
|
263
|
-
export function runCli(session, args) {
|
|
264
|
-
const { ctx, target } = session;
|
|
265
|
-
const [command, ...lead] = supabaseCommand(ctx.cwd, target.nativeTarget);
|
|
266
|
-
const env = target.nativeTarget ? nativeDatabaseEnvironment(ctx.env, target.nativeTarget, target.env, [target.privateInputEnv]) : { ...ctx.env, ...target.env };
|
|
267
|
-
const workdir = target.nativeTarget ? args[args.indexOf('--workdir') + 1] : ctx.cwd;
|
|
268
|
-
if (target.nativeTarget && !session.nativeWorkdirs?.has(workdir)) throw refusal('DB_CONFIG_INVALID', 'the bound database command requires its clean public scratch project');
|
|
269
|
-
const run = tool(ctx, command, [...lead, ...args, ...(target.nativeTarget ? ['--profile', 'supabase', '--output-format', 'json', '--agent', 'no'] : [])], { cwd: workdir, env });
|
|
270
|
-
if (run.error) throw failure('DB_CLI_MISSING', `cannot run the Supabase CLI (${scrub(run.error.message, target.secrets)})`);
|
|
271
|
-
if (run.status !== 0) throw failure('DB_CLI_FAILED', `supabase ${args.slice(0, 2).join(' ')} failed: ${scrub(firstLine(run.stderr), target.secrets)}`);
|
|
272
|
-
return { stdout: run.stdout ?? '', text: `${run.stdout ?? ''}${run.stderr ?? ''}` };
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/** A bound consumer requires the native machine result; unreadable output cannot become an empty database history. */
|
|
276
|
-
function boundRemoteVersions(stdout) {
|
|
277
|
-
let data;
|
|
278
|
-
try { data = JSON.parse(stdout); } catch { throw failure('DB_CLI_FAILED', 'the bound migration history is not readable JSON'); }
|
|
279
|
-
if (!Array.isArray(data?.migrations) || data.migrations.some(item => !plain(item)
|
|
280
|
-
|| typeof item.local !== 'string' || typeof item.remote !== 'string'
|
|
281
|
-
|| item.local !== '' && !STAMP.test(item.local) || item.remote !== '' && !STAMP.test(item.remote)
|
|
282
|
-
|| !item.local && !item.remote))
|
|
283
|
-
throw failure('DB_CLI_FAILED', 'the bound migration history has an unexpected shape');
|
|
284
|
-
return new Set(data.migrations.map(item => item.remote).filter(Boolean));
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
/** The remote history as a Set of versions; bound callers require noninteractive structured output. */
|
|
288
|
-
export function listRemote(session) {
|
|
289
|
-
if (!session.target.nativeTarget) return parseMigrationList(runCli(session, ['migration', 'list', ...targetArgs(session.target, session.ctx.cwd)]).text).remote;
|
|
290
|
-
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'starci-db-'));
|
|
291
|
-
try {
|
|
292
|
-
const { model } = session;
|
|
293
|
-
buildScratch(session, scratch, model.push.map(file => ({ from: path.join(model.root, model.decl.migrationsDir, file), name: file })));
|
|
294
|
-
return boundRemoteVersions(runCli(session, ['migration', 'list', ...targetArgs(session.target, scratch)]).stdout);
|
|
295
|
-
} finally {
|
|
296
|
-
session.nativeWorkdirs?.delete(scratch);
|
|
297
|
-
fs.rmSync(scratch, { recursive: true, force: true });
|
|
298
|
-
}
|
|
299
|
-
}
|
|
145
|
+
/** The recorded history as a Set of versions. */
|
|
146
|
+
export const listRemote = (session) => historyVersions(session.ctx, session.target);
|
|
300
147
|
|
|
301
148
|
/* ---- the plan */
|
|
302
149
|
|
|
303
|
-
/** Pure: what to do given the
|
|
304
|
-
function planMigration(model, remote) {
|
|
150
|
+
/** Pure: what to do given the recorded history. `done` means baseline + forward are present and nothing else is. */
|
|
151
|
+
export function planMigration(model, remote) {
|
|
305
152
|
const done = [...model.pushVersions].every((version) => remote.has(version)) && [...remote].every((version) => model.allowed.has(version));
|
|
306
153
|
const sim = new Set(remote);
|
|
307
154
|
const repairs = model.decl.restamp.filter((pair) => sim.has(pair.from));
|
|
@@ -309,27 +156,33 @@ function planMigration(model, remote) {
|
|
|
309
156
|
sim.delete(pair.from);
|
|
310
157
|
sim.add(pair.to);
|
|
311
158
|
}
|
|
312
|
-
const
|
|
159
|
+
const hasBaseline = model.baseline !== null;
|
|
160
|
+
const reverts = !hasBaseline || sim.has(model.baseline) ? [] : model.archive.map((item) => item.version).filter((version) => sim.has(version));
|
|
313
161
|
for (const version of reverts) sim.delete(version);
|
|
314
|
-
|
|
315
|
-
sim.
|
|
316
|
-
|
|
162
|
+
// A baseline is marked (never run) only on a database that already holds history; an empty database runs it as the first migration.
|
|
163
|
+
const mark = hasBaseline && !sim.has(model.baseline) && remote.size > 0;
|
|
164
|
+
if (mark) sim.add(model.baseline);
|
|
165
|
+
const forward = hasBaseline && !sim.has(model.baseline) ? [model.baseline, ...model.pushPending] : model.pushPending;
|
|
166
|
+
const push = forward.filter((version) => !sim.has(version));
|
|
317
167
|
for (const version of push) sim.add(version);
|
|
318
168
|
const archived = new Set(model.archive.map((item) => item.version));
|
|
319
169
|
const missingFiles = repairs.map((pair) => pair.to).filter((version) => !archived.has(version));
|
|
320
|
-
|
|
170
|
+
const bootstrap = remote.size === 0 && push.length > 0 && fs.existsSync(path.join(model.root, BOOTSTRAP_FILE));
|
|
171
|
+
return { done, repairs, reverts, mark, push, bootstrap, missingFiles, unexpected: [...sim].filter((version) => !model.allowed.has(version)), final: sim.size };
|
|
321
172
|
}
|
|
322
173
|
|
|
174
|
+
const fileOf = (model, version) => model.files.find((file) => file.startsWith(`${version}_`));
|
|
175
|
+
|
|
323
176
|
function planLines(model, session, remote, plan) {
|
|
324
|
-
const nameOf = (version) => model.files.find((file) => file.startsWith(`${version}_`));
|
|
325
177
|
return [
|
|
326
178
|
`target ${session.target.how}`,
|
|
327
|
-
`
|
|
179
|
+
`history ${remote.size} version(s)`,
|
|
328
180
|
[`repairs ${plan.repairs.length} restamped version(s) to move`, ...plan.repairs.map((pair) => ` ${pair.from} -> ${pair.to}`)].join('\n'),
|
|
329
|
-
`archive revert ${plan.reverts.length} archived version(s)
|
|
330
|
-
`baseline ${model.baseline} ${plan.mark ? 'will be marked applied (not run)' : '
|
|
331
|
-
`forward
|
|
181
|
+
`archive revert ${plan.reverts.length} archived version(s) recorded`,
|
|
182
|
+
`baseline ${model.baseline === null ? 'none declared' : `${model.baseline} ${plan.mark ? 'will be marked applied (not run)' : 'applied or run in order'}`}`,
|
|
183
|
+
`forward ${plan.push.length} migration(s): ${plan.push.map((version) => fileOf(model, version)).join(', ') || 'none'}`,
|
|
332
184
|
`held back ${model.held.join(', ') || 'none'} (never applied here; starci app db post-cutover)`,
|
|
185
|
+
`bootstrap ${plan.bootstrap ? `${BOOTSTRAP_FILE} runs first (the history is empty)` : 'not needed'}`,
|
|
333
186
|
];
|
|
334
187
|
}
|
|
335
188
|
|
|
@@ -340,90 +193,61 @@ const dataOf = (model, plan, remote, extra) => ({
|
|
|
340
193
|
|
|
341
194
|
/* ---- applying */
|
|
342
195
|
|
|
343
|
-
/**
|
|
344
|
-
function
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
const repair = (session, status, versions, dir) => runCli(session, ['migration', 'repair', '--status', status, ...versions, ...targetArgs(session.target, dir)]);
|
|
353
|
-
|
|
354
|
-
function applyRepairs(session, plan, scratch) {
|
|
355
|
-
const dir = path.join(scratch, 'archive');
|
|
356
|
-
buildScratch(session, dir, session.model.archive.map((item) => ({ from: item.from, name: item.name })));
|
|
357
|
-
repair(session, 'reverted', plan.repairs.map((pair) => pair.from), dir);
|
|
358
|
-
repair(session, 'applied', plan.repairs.map((pair) => pair.to), dir);
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
function applyForward(session, plan, scratch) {
|
|
362
|
-
const { model } = session;
|
|
363
|
-
const dir = path.join(scratch, 'baseline');
|
|
364
|
-
buildScratch(session, dir, model.push.map((file) => ({ from: path.join(model.root, model.decl.migrationsDir, file), name: file })));
|
|
365
|
-
if (plan.reverts.length > 0) repair(session, 'reverted', plan.reverts, dir);
|
|
366
|
-
if (plan.mark) repair(session, 'applied', [model.baseline], dir);
|
|
367
|
-
const pending = pendingVersions(runCli(session, ['db', 'push', '--dry-run', ...targetArgs(session.target, dir)]).text);
|
|
368
|
-
const stray = pending.filter((version) => !model.pushPending.includes(version));
|
|
369
|
-
if (stray.length > 0) throw failure('DB_PUSH_STRAY', `the dry run lists versions outside the push list: ${stray.join(', ')}. Stopped before pushing.`);
|
|
370
|
-
if (pending.length > 0) runCli(session, ['db', 'push', '--yes', ...targetArgs(session.target, dir)]);
|
|
371
|
-
return pending;
|
|
196
|
+
/** Applies `versions` in order: each file and its history row in one transaction, so a failure leaves the history true. */
|
|
197
|
+
export function applyVersions(session, versions) {
|
|
198
|
+
const { ctx, target, model } = session;
|
|
199
|
+
for (const version of versions) {
|
|
200
|
+
const file = fileOf(model, version);
|
|
201
|
+
psqlTransaction(ctx, target, [{ sql: HISTORY_DDL }, { file: path.join(model.root, model.decl.migrationsDir, file) }, { sql: markApplied(version, nameOf(file)) }]);
|
|
202
|
+
}
|
|
203
|
+
return versions;
|
|
372
204
|
}
|
|
373
205
|
|
|
374
206
|
function applyPlan(session, plan) {
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
}
|
|
207
|
+
const { ctx, target, model } = session;
|
|
208
|
+
if (plan.bootstrap) psqlTransaction(ctx, target, [{ file: path.join(model.root, BOOTSTRAP_FILE) }]);
|
|
209
|
+
const history = [
|
|
210
|
+
...plan.repairs.flatMap((pair) => [markReverted(pair.from), markApplied(pair.to, nameOf(model.archive.find((item) => item.version === pair.to)?.name ?? `${pair.to}_restamped.sql`))]),
|
|
211
|
+
...plan.reverts.map(markReverted),
|
|
212
|
+
...(plan.mark ? [markApplied(model.baseline, nameOf(model.baselineFile))] : []),
|
|
213
|
+
];
|
|
214
|
+
if (history.length > 0) psqlTransaction(ctx, target, [{ sql: HISTORY_DDL }, ...history.map((sql) => ({ sql }))]);
|
|
215
|
+
return applyVersions(session, plan.push);
|
|
383
216
|
}
|
|
384
217
|
|
|
385
218
|
/**
|
|
386
|
-
* `starci app db migrate`. options: { execute,
|
|
387
|
-
* STARCI_DB_ARMED=1
|
|
219
|
+
* `starci app db migrate`. options: { execute, urlEnv }. Dry run reads the history once and prints the plan; --execute applies it,
|
|
220
|
+
* armed with STARCI_DB_ARMED=1 unless the database is on the loopback (the local db service of `npm run db:push`). Already baseline + forward: `nothing to do`, no mutating call, ok.
|
|
388
221
|
*/
|
|
389
|
-
async function migrateDatabase(ctx, decl, options = {}) {
|
|
390
|
-
if (options.nativeTarget) boundSupabaseLauncher(ctx.cwd);
|
|
222
|
+
export async function migrateDatabase(ctx, decl, options = {}) {
|
|
391
223
|
const model = buildModel(ctx.cwd, decl);
|
|
392
|
-
const session = { ctx, model, target: resolveTarget(ctx,
|
|
224
|
+
const session = { ctx, model, target: resolveTarget(ctx, options) };
|
|
393
225
|
const remote = listRemote(session);
|
|
394
226
|
const plan = planMigration(model, remote);
|
|
395
|
-
if (plan.done) return { ok: true, lines: [`target ${session.target.how}`, '
|
|
227
|
+
if (plan.done) return { ok: true, lines: [`target ${session.target.how}`, 'the history already is the baseline plus the forward migrations (held-back ones may or may not be applied). Nothing to do.'], data: dataOf(model, plan, remote, { executed: false }) };
|
|
396
228
|
const lines = planLines(model, session, remote, plan);
|
|
397
229
|
const red = [
|
|
398
230
|
...plan.missingFiles.map((version) => `no archived migration file for the restamped version ${version}`),
|
|
399
|
-
...plan.unexpected.map((version) => `
|
|
231
|
+
...plan.unexpected.map((version) => `recorded version ${version} is outside baseline + forward + held-back and no restamp or archive row explains it`),
|
|
400
232
|
];
|
|
401
233
|
if (red.length > 0) return { ok: false, lines: [...lines, ...red.map((item) => `RED: ${item}`), 'nothing applied.'], data: dataOf(model, plan, remote, { red, executed: false }) };
|
|
402
|
-
if (options.execute !== true) return { ok: true, lines: [...lines, `dry run: read-only (one
|
|
403
|
-
requireArmed(ctx);
|
|
234
|
+
if (options.execute !== true) return { ok: true, lines: [...lines, `dry run: read-only (one history read); the final history would be ${plan.final} version(s). Nothing applied.`], data: dataOf(model, plan, remote, { executed: false }) };
|
|
235
|
+
if (!isLoopback(session.target.url)) requireArmed(ctx);
|
|
404
236
|
const pushed = applyPlan(session, plan);
|
|
405
237
|
const final = listRemote(session);
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
return { ok: true, lines: [...lines, `applied: ${pushed.length} forward migration(s) pushed; the remote history now holds ${final.size} version(s). Held back: ${model.held.join(', ') || 'nothing'}.`], data: dataOf(model, plan, remote, { executed: true, pushed, remoteCountAfter: final.size }) };
|
|
238
|
+
if (!planMigration(model, final).done) throw failure('DB_FINAL_MISMATCH', `the history is not baseline + forward migrations (${[...final].join(', ')})`);
|
|
239
|
+
return { ok: true, lines: [...lines, `applied: ${pushed.length} forward migration(s); the history now holds ${final.size} version(s). Held back: ${model.held.join(', ') || 'nothing'}.`], data: dataOf(model, plan, remote, { executed: true, pushed, remoteCountAfter: final.size }) };
|
|
409
240
|
}
|
|
410
241
|
|
|
411
|
-
const SPEC = { boolean: ['--execute', '--json'
|
|
242
|
+
const SPEC = { boolean: ['--execute', '--json'], value: ['--url-env'] };
|
|
412
243
|
|
|
413
|
-
/** Entry of the verb:
|
|
244
|
+
/** Entry of the verb: `starci app db migrate [--execute] [--url-env <NAME>] [--json]`. Execute requires database arming. */
|
|
414
245
|
export function dbMigrateMain(argv, io = {}) {
|
|
415
246
|
return runVerb({
|
|
416
247
|
label: 'starci app db migrate', name: 'db migrate', code: 'DB_USAGE', io, argv, spec: SPEC,
|
|
417
|
-
run: (ctx, flags) => {
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
throw refusal('DB_USAGE', 'the native database selector differs from its accepted binding');
|
|
421
|
-
if (flags.local) {
|
|
422
|
-
if (flags.linked || flags['url-env'] !== undefined) throw refusal('DB_USAGE', '--local excludes --linked and --url-env');
|
|
423
|
-
if (flags.execute) requireArmed(ctx);
|
|
424
|
-
return migrateLocalDatabase(ctx, { execute: flags.execute === true, nativeTarget });
|
|
425
|
-
}
|
|
426
|
-
return migrateDatabase(ctx, loadDatabase(ctx.cwd), { execute: flags.execute === true, linked: flags.linked === true, urlEnv: flags['url-env'], nativeTarget });
|
|
248
|
+
run: (ctx, flags, positional) => {
|
|
249
|
+
if (positional.length) throw refusal('DB_USAGE', 'migrate takes flags only');
|
|
250
|
+
return migrateDatabase(ctx, loadDatabase(ctx.cwd), { execute: flags.execute === true, urlEnv: flags['url-env'] });
|
|
427
251
|
},
|
|
428
252
|
});
|
|
429
253
|
}
|
package/db/new.mjs
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// new.mjs - `starci app db new <kebab-name>`: creates the next migration db/migrations/<ts14>_<kebab-name>.sql (slot app.db.migrations),
|
|
2
|
+
// stamped with the current UTC instant and strictly after every committed active migration, so DB_MIGRATION_SHAPE holds from birth.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { byCodeUnit } from '../runtime/engine/by-code-unit.mjs';
|
|
6
|
+
import { MIGRATIONS_DIR } from '../runtime/scripts/hfs/rules/database-constants.mjs';
|
|
7
|
+
import { refusal } from '../stack/errors.mjs';
|
|
8
|
+
import { runVerb } from '../release/index.mjs';
|
|
9
|
+
|
|
10
|
+
const KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
11
|
+
const STAMPED = /^(\d{14})_/u;
|
|
12
|
+
|
|
13
|
+
/** The fourteen-digit UTC stamp of `ms`. */
|
|
14
|
+
export const stampAt = (ms) => new Date(ms).toISOString().replace(/[-:T]/gu, '').slice(0, 14);
|
|
15
|
+
|
|
16
|
+
/** The next stamp: now, or one second after the newest active migration when the clock is behind it. */
|
|
17
|
+
export function nextStamp(existing, nowMs) {
|
|
18
|
+
const last = existing.map((file) => STAMPED.exec(file)?.[1]).filter(Boolean).sort(byCodeUnit).at(-1);
|
|
19
|
+
const now = stampAt(nowMs);
|
|
20
|
+
if (last === undefined || now > last) return now;
|
|
21
|
+
const at = Date.UTC(Number(last.slice(0, 4)), Number(last.slice(4, 6)) - 1, Number(last.slice(6, 8)), Number(last.slice(8, 10)), Number(last.slice(10, 12)), Number(last.slice(12, 14))) + 1000;
|
|
22
|
+
return stampAt(at);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Writes an empty forward-only migration named `name` and answers its app-relative path. */
|
|
26
|
+
export function newMigration(ctx, name) {
|
|
27
|
+
if (typeof name !== 'string' || !KEBAB.test(name)) throw refusal('DB_USAGE', 'db new takes one kebab-case name (letters, digits and single dashes)');
|
|
28
|
+
const dir = path.join(ctx.cwd, MIGRATIONS_DIR);
|
|
29
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
30
|
+
const existing = fs.readdirSync(dir).filter((file) => file.endsWith('.sql'));
|
|
31
|
+
const file = `${nextStamp(existing, ctx.now())}_${name}.sql`;
|
|
32
|
+
fs.writeFileSync(path.join(dir, file), `-- ${name}: forward-only; immutable once on origin/main.\n`, { flag: 'wx' });
|
|
33
|
+
const rel = `${MIGRATIONS_DIR}/${file}`;
|
|
34
|
+
return { ok: true, lines: [`created ${rel}`], data: { path: rel } };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Entry of the verb: `starci app db new <kebab-name> [--json]`. */
|
|
38
|
+
export function dbNewMain(argv, io = {}) {
|
|
39
|
+
return runVerb({
|
|
40
|
+
label: 'starci app db new', name: 'db new', code: 'DB_USAGE', io, argv, spec: { boolean: ['--json'], value: [] },
|
|
41
|
+
run: (ctx, _flags, positional) => {
|
|
42
|
+
if (positional.length !== 1) throw refusal('DB_USAGE', 'db new takes exactly one migration name');
|
|
43
|
+
return newMigration(ctx, positional[0]);
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
}
|