@starci/hfs 4.9.11 → 4.10.1
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 +18 -0
- package/README.md +7 -9
- package/db/backup-receipt.mjs +10 -47
- package/db/drift.mjs +1 -1
- package/db/dump.mjs +11 -10
- package/db/entity-list.mjs +1 -1
- package/db/migrate.mjs +83 -259
- 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/package.json +1 -1
- package/release/gate.mjs +3 -15
- package/runtime/knowledge/hfs/canon-pins.yaml +44 -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 +179 -157
- package/runtime/knowledge/hfs/slots.yaml +185 -172
- 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 +65 -115
- package/runtime/modules/schemas/application-stacks.schema.yaml +5 -5
- package/runtime/scripts/hfs/architecture/canon-clones.mjs +1 -1
- package/runtime/scripts/hfs/architecture/entity-registry.mjs +53 -0
- 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/owners.mjs +11 -0
- 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/tiers.mjs +14 -3
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +7 -3
- package/runtime/scripts/hfs/check.mjs +6 -6
- 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 +9 -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 +19 -20
- package/stack/target-binding.mjs +0 -60
- package/stack/urgent-floor.mjs +2 -2
- package/sync/index.mjs +32 -8
- 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/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-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 +6 -6
- 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 +7 -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/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
|
@@ -18,7 +18,8 @@ import { propertyValue } from './function-spec-providers.mjs';
|
|
|
18
18
|
* - no file outside the client folder and the stream reader folder (`fe.transport.stream`, `modules/api/sse/clients/`) references the global `fetch`: a call, `globalThis.fetch`, `window.fetch`, or a
|
|
19
19
|
* reference passed as a value (`useSWR(key, fetch)`, `const send = fetch`), all of which a spelling-based rule misses; with no
|
|
20
20
|
* client at all every such reference is a finding that says the repository has no client;
|
|
21
|
-
* - axios is imported only by the api owner (modules/api, the api package) for its rest calls
|
|
21
|
+
* - axios is imported only by the api owner (modules/api, the api package) for its rest calls and by the auth door
|
|
22
|
+
* (modules/auth/auth-door.ts) for the app's own auth doors; every other HTTP library is a
|
|
22
23
|
* second transport wherever it is imported, by static import, `import()` or `require()`;
|
|
23
24
|
* - an `ApolloClient` is constructed only inside the client folder;
|
|
24
25
|
* - every GraphQL operation (`graphql/queries/query-*.ts`, `graphql/mutations/mutation-*.ts`, `graphql/subscriptions/subscription-*.ts`) imports the client (or the api
|
|
@@ -103,13 +104,16 @@ function isApolloClientConstruction(ts, kit, checker, node) {
|
|
|
103
104
|
return Boolean(binding?.name === 'ApolloClient' && binding.module.startsWith('@apollo/client'));
|
|
104
105
|
}
|
|
105
106
|
|
|
107
|
+
/** The one auth door of the session issuer's client (`modules/auth/auth-door.ts`, slot fe.modules.auth): it calls the app's own auth doors with axios. */
|
|
108
|
+
const isAuthDoor = (file) => file.slot === 'fe.modules.auth' && path.posix.basename(file.rel) === 'auth-door.ts';
|
|
109
|
+
|
|
106
110
|
function inspectLibraryImport({ ts, file, node, transport, report }) {
|
|
107
111
|
const specifier = moduleSpecifierOf(ts, node);
|
|
108
112
|
if (!specifier || !ts.isStringLiteralLike(specifier)) return;
|
|
109
113
|
const library = specifier.text.split('/')[0];
|
|
110
|
-
if (!HTTP_LIBRARIES.has(library) || (library === REST_LIBRARY && API_OWNER_SLOTS.has(file.slot))) return;
|
|
114
|
+
if (!HTTP_LIBRARIES.has(library) || (library === REST_LIBRARY && (API_OWNER_SLOTS.has(file.slot) || isAuthDoor(file)))) return;
|
|
111
115
|
const where = library === REST_LIBRARY
|
|
112
|
-
? 'axios belongs to the api owner
|
|
116
|
+
? 'axios belongs to the api owner (modules/api/rest, the api package) and the auth door (modules/auth/auth-door.ts) only: call their functions'
|
|
113
117
|
: `the transport is Apollo GraphQL (${transport}) with axios for the rest calls of modules/api/rest`;
|
|
114
118
|
report(file, node, `${specifier.text} is a second HTTP transport in ${file.rel}; ${where}.`, { library: specifier.text });
|
|
115
119
|
}
|
|
@@ -26,12 +26,12 @@
|
|
|
26
26
|
// FE_NO_TESTS (R97, rules/fe-no-tests.mjs) a front end holds a spec, e2e or test-tool file, a test script or a test dependency; no exception
|
|
27
27
|
// FE_STACK_FORBIDDEN (rules/fe-stack.mjs) a front-end manifest (the fe side's, or the app root's minus the back end's own libraries)
|
|
28
28
|
// declares a package knowledge/hfs/fe-stack.yaml forbids
|
|
29
|
-
// HFS_LITE_SECRET_CUSTODY (R220, rules/
|
|
29
|
+
// HFS_LITE_SECRET_CUSTODY (R220, rules/lite-secrets.mjs) under edition lite: secret material or a forbidden custody path; the R06 scan itself runs for every edition
|
|
30
30
|
// HFS_EDITION_FORBIDDEN_PRESENT (R212, rules/edition.mjs) under edition lite: an e2e, contract, test-stack or browser script, or a
|
|
31
31
|
// dependency of infrastructure lite does not run (test containers, fault proxy, Kafka)
|
|
32
32
|
// HFS_BACKING_UNDECLARED, HFS_BACKING_UNUSED (R212, rules/edition.mjs) under edition lite: a client library or a pattern of a backing
|
|
33
33
|
// service hfs.json sides.be.backing does not declare or the edition does not admit, or a declared service no dependency consumes
|
|
34
|
-
// DB_MIGRATION_SHAPE ...
|
|
34
|
+
// DB_MIGRATION_SHAPE ... LITE_DB_MIGRATION (R213-R215, R332, rules/database.mjs, async: passed in as extraFindings) the db/ migrations, policies, definer functions and buckets; generated types
|
|
35
35
|
// BE_SPEC_PLACEMENT (R102, rules/spec-placement.mjs) a spec or test file outside the four test layers, scripts/ and tools/ included
|
|
36
36
|
// HFS_REPO_LOCAL_CHECK (R103, rules/repo-local-checks.mjs) a local eslint rule or plugin, a `check-*` script, a relative import in eslint.config
|
|
37
37
|
// HFS_LINT_SUPPRESSION_FILE (R104, rules/lint-suppression.mjs) an eslint suppressions file, script or option
|
|
@@ -83,7 +83,7 @@ import { frontendFindings } from './rules/frontend-tree.mjs';
|
|
|
83
83
|
import { lintSuppressionFindings } from './rules/lint-suppression.mjs';
|
|
84
84
|
import { bypassMarkerFindings } from './rules/bypass-markers.mjs';
|
|
85
85
|
import { repoLocalCheckFindings } from './rules/repo-local-checks.mjs';
|
|
86
|
-
import {
|
|
86
|
+
import { liteSecretFindings } from './rules/lite-secrets.mjs';
|
|
87
87
|
import { untrackedSecretFindings } from './rules/secrets.mjs';
|
|
88
88
|
import { liteStarciworkFinding, pathFindings } from './path-findings.mjs';
|
|
89
89
|
import { onLintSurface } from './architecture/surface.mjs';
|
|
@@ -111,8 +111,8 @@ export const CHECK_CODES = Object.freeze([
|
|
|
111
111
|
'HFS_PLAINTEXT_SECRET', 'HFS_STACKS_SHAPE', 'HFS_CI_MISSING_CANON', 'HFS_DEP_VERSION_SKEW', 'HFS_CONTRACT_SNAPSHOT_DRIFT', 'HFS_MCP_TOOLS_SNAPSHOT',
|
|
112
112
|
'BE_TEST_TOPOLOGY', 'BE_SPEC_PLACEMENT', 'HFS_REPO_LOCAL_CHECK', 'HFS_LINT_SUPPRESSION_FILE', 'HFS_BYPASS_MARKER', 'HFS_PROOF_COMMAND_FILE_MISSING', 'HFS_PEER_INTEGRATION_MISSING', 'BE_INTEGRATION_SPEC_MISSING', 'FE_GRAPHQL_CONTRACT', 'FE_NO_TESTS', 'HFS_MONO_WORKSPACES', 'HFS_MONO_FE_WORKSPACE', 'HFS_MONO_NEST_PROJECTS', 'HFS_MONO_WORKSPACE_DEP', 'BE_CLI_REQUIRED', 'FE_WIRE_GENERATED', 'FE_I18N_PLACEMENT', 'FE_I18N_CATALOG', 'HFS_SERVICE_PLACEMENT', 'HFS_IMAGE_UNPINNED', 'HFS_SERVICE_STACK_DECLARATION', 'HFS_EVENT_CONTRACT', 'BE_ASYNC_SPEC_MISSING', 'BE_SAGA_STEP_COMPENSATION', 'BE_SAGA_STATE_VERSIONED', 'BE_SAGA_EVENT_CONTRACT', 'BE_SAGA_CONSUMER_DEDUPE', 'BE_EVENT_CLASS_CONTRACT', 'BE_PATTERN_SPEC_MISSING', 'BE_CONTRACT_BREAKING', 'BE_KIND_DECLARATION', 'BE_KIND_EMPTY', 'HFS_DOCKER_BUILD_CONTEXT', 'HFS_DOCKER_STAGES', 'HFS_DOCKER_ENTRY', 'HFS_DOCKER_BASE_PIN', 'HFS_DOCKER_SECRETS', 'BE_SAGA_E2E_MISSING',
|
|
113
113
|
'FE_STACK_FORBIDDEN', 'HFS_EDITION_FORBIDDEN_PRESENT', 'HFS_BACKING_UNDECLARED', 'HFS_BACKING_UNUSED', 'HFS_LITE_SECRET_CUSTODY', 'HFS_STARCIWORK_LITE_FORBIDDEN',
|
|
114
|
-
'DB_MIGRATION_SHAPE', 'DB_RLS_REQUIRED', 'DB_DYNAMIC_DDL', 'DB_POLICY_SHAPE', 'DB_DEFINER_SAFE', 'DB_STORAGE_POLICY', '
|
|
115
|
-
'HFS_DOCKER_HEALTHCHECK_FAKE', '
|
|
114
|
+
'DB_MIGRATION_SHAPE', 'DB_RLS_REQUIRED', 'DB_DYNAMIC_DDL', 'DB_POLICY_SHAPE', 'DB_DEFINER_SAFE', 'DB_STORAGE_POLICY', 'LITE_DB_MIGRATION',
|
|
115
|
+
'HFS_DOCKER_HEALTHCHECK_FAKE', 'DB_APP_ROLE', 'DB_FORCE_RLS_DEFAULT',
|
|
116
116
|
'DB_POLICY_TAUTOLOGY', 'DB_VIEW_UNSAFE', 'DB_STORAGE_PREFIX', 'DB_DEFINER_REVOKE',
|
|
117
117
|
'HFS_GITIGNORE_BLOCK_DRIFT', 'HFS_SONAR_CONFIG', 'HFS_COVERAGE_SCOPE_DRIFT', 'HFS_FORMAT',
|
|
118
118
|
'HFS_EMPTY_DIR', 'HFS_GHOST_TREE', 'HFS_UNTRACKED_ROOT_ENTRY',
|
|
@@ -209,7 +209,7 @@ function minimumInstanceFindings(resolver, required, instances) {
|
|
|
209
209
|
function contentRuleFindings({ repoRoot, repo, resolver, files, all, scoped, pins, inScope, isRoot, appSide, editionDeclaration, manifest, phase }) {
|
|
210
210
|
const findings = [
|
|
211
211
|
...pinFindings({ repoRoot, files, profile: repo.profile, pins, only: scoped }),
|
|
212
|
-
...
|
|
212
|
+
...liteSecretFindings({ repoRoot, files: files.filter((file) => inScope(file)), resolver, repo }),
|
|
213
213
|
...editionFindings({ repoRoot, files: files.filter((file) => inScope(file)), repo, resolver, manifest, withDeclaration: editionDeclaration }),
|
|
214
214
|
...feStackFindings({ repoRoot, files: files.filter((file) => inScope(file)), repo, manifest }),
|
|
215
215
|
...repoLocalCheckFindings({ repoRoot, files }),
|
|
@@ -52,7 +52,7 @@ export function nested(inner, outer) {
|
|
|
52
52
|
const variantsOf = (slot) => braceVariants(slot.path).map(star);
|
|
53
53
|
|
|
54
54
|
/**
|
|
55
|
-
* The tracked slots of the be profile, in manifest order. A slot gated by a connection provider
|
|
55
|
+
* The tracked slots of the be profile, in manifest order. A slot gated by a connection provider holds files only in an
|
|
56
56
|
* app whose connection declares that provider (`providers`), so it takes no part in the scope of any other app.
|
|
57
57
|
*/
|
|
58
58
|
const beSlots = (manifest, providers) => manifest.slots.filter((slot) => slot.profiles.includes('be') && slot.tracked === 'tracked' && (slot.provider === undefined || providers.includes(slot.provider)));
|
|
@@ -4,9 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import pg from 'libpg-query';
|
|
5
5
|
import { invocationDir } from '../lib/roots.mjs';
|
|
6
6
|
import { sqlStringParts as names, walkSqlAst as walk, sqlParser } from '../lib/sql-ast.mjs';
|
|
7
|
-
import {
|
|
8
|
-
import { databaseQuery } from '../api/supabase/database-query.mjs';
|
|
9
|
-
import { migrationRepair } from '../api/supabase/migration-repair.mjs';
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
10
8
|
import { migrationStamp, isMigrationName, databaseSqlTokens, databaseExecutableTokens } from './rules/database-migrations.mjs';
|
|
11
9
|
import { baselineMarkers, baselineSourceFindings } from './rules/database-baseline.mjs';
|
|
12
10
|
import { databaseQueueDeclarations } from './rules/database-queues.mjs';
|
|
@@ -16,10 +14,6 @@ import { dbPlanMain } from './db-plan-verb.mjs';
|
|
|
16
14
|
await pg.loadModule();
|
|
17
15
|
|
|
18
16
|
export class DbVerbError extends Error {}
|
|
19
|
-
const LINKED_PASSWORD_ENV = 'SUPABASE_DB_PASSWORD';
|
|
20
|
-
const TELEMETRY_DISABLED_ENV = 'SUPABASE_TELEMETRY_DISABLED';
|
|
21
|
-
const TELEMETRY_DEBUG_ENV = 'SUPABASE_TELEMETRY_DEBUG';
|
|
22
|
-
const CLI_DEBUG_ENV = 'SUPABASE_DEBUG';
|
|
23
17
|
|
|
24
18
|
/** Strict shared options; unknown flags never reach the database CLI. */
|
|
25
19
|
export function dbOptions(argv, allowed) {
|
|
@@ -28,7 +22,7 @@ export function dbOptions(argv, allowed) {
|
|
|
28
22
|
const [flag, inline] = argv[index].split(/=(.*)/s);
|
|
29
23
|
const name = flag.slice(2);
|
|
30
24
|
if (!flag.startsWith('--') || !allowed.includes(name) || Object.hasOwn(opts, name)) throw new DbVerbError('unknown or duplicate database option');
|
|
31
|
-
if (['apply', '
|
|
25
|
+
if (['apply', 'json'].includes(name)) {
|
|
32
26
|
if (inline !== undefined && !['true', 'false'].includes(inline)) throw new DbVerbError(`${flag} expects true or false`);
|
|
33
27
|
opts[name] = inline !== 'false';
|
|
34
28
|
} else {
|
|
@@ -48,7 +42,7 @@ export function dbAppRoot(cwd, opts) {
|
|
|
48
42
|
return root;
|
|
49
43
|
}
|
|
50
44
|
|
|
51
|
-
/** Immediate SQL files only, never secrets or
|
|
45
|
+
/** Immediate SQL files only, never secrets or credential files. */
|
|
52
46
|
export function dbSqlFiles(root, directory = MIGRATIONS_DIR) {
|
|
53
47
|
const dir = path.join(root, directory);
|
|
54
48
|
if (!fs.existsSync(dir)) return [];
|
|
@@ -59,27 +53,50 @@ export function dbSqlFiles(root, directory = MIGRATIONS_DIR) {
|
|
|
59
53
|
}).sort((left, right) => left.name < right.name ? -1 : Number(left.name > right.name));
|
|
60
54
|
}
|
|
61
55
|
|
|
62
|
-
/**
|
|
63
|
-
|
|
56
|
+
/** The database of `--url-env` (default DATABASE_URL): the URL without its password and the password for PGPASSWORD. */
|
|
57
|
+
function connectionOf(env, urlEnv) {
|
|
58
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(urlEnv)) throw new DbVerbError('--url-env must name an UPPER_CASE environment variable');
|
|
59
|
+
let url;
|
|
60
|
+
try { url = new URL(String(env[urlEnv] ?? '')); } catch { throw new DbVerbError(`${urlEnv} must hold a postgres connection URL`); }
|
|
61
|
+
if (!/^postgres(?:ql)?:$/.test(url.protocol)) throw new DbVerbError(`${urlEnv} must hold a postgres connection URL`);
|
|
62
|
+
const password = decodeURIComponent(url.password);
|
|
63
|
+
url.password = '';
|
|
64
|
+
return { url: url.toString(), env: { ...env, ...(password ? { PGPASSWORD: password } : {}) } };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const HISTORY = 'supabase_migrations.schema_migrations';
|
|
68
|
+
const sqlLiteral = (value) => `'${String(value).replaceAll("'", "''")}'`;
|
|
64
69
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
return
|
|
70
|
+
/** The psql and pg_dump invocation of one operation: a JSON rows query, a schema-only dump, or one history row applied or reverted. */
|
|
71
|
+
function commandOf(op, url) {
|
|
72
|
+
const psql = ['-X', '-q', '-v', 'ON_ERROR_STOP=1', '-d', url];
|
|
73
|
+
if (op.kind === 'query') return ['psql', [...psql, '-A', '-t', '-c', `select coalesce(json_agg(row_to_json(q)), '[]'::json) from (${op.sql.replace(/;\s*$/, '')}) q`]];
|
|
74
|
+
if (op.kind === 'dump') return ['pg_dump', ['--schema-only', '--schema', 'public', '--dbname', url]];
|
|
75
|
+
if (op.kind === 'repair') {
|
|
76
|
+
const change = op.status === 'applied'
|
|
77
|
+
? `insert into ${HISTORY} (version, name) values (${sqlLiteral(op.stamp)}, ${sqlLiteral(op.name ?? 'baseline')}) on conflict (version) do nothing`
|
|
78
|
+
: `delete from ${HISTORY} where version = ${sqlLiteral(op.stamp)}`;
|
|
79
|
+
return ['psql', [...psql, '-1', '-c', `create schema if not exists supabase_migrations; create table if not exists ${HISTORY} (version text primary key, statements text[], name text)`, '-c', change]];
|
|
80
|
+
}
|
|
81
|
+
throw new DbVerbError('unsupported database command');
|
|
69
82
|
}
|
|
70
83
|
|
|
71
|
-
|
|
84
|
+
/** Runs one database operation through psql or pg_dump (ports.runPostgres in specs); raw failures and output are private. */
|
|
85
|
+
export function runDbPostgres(root, op, ports = {}, urlEnv = 'DATABASE_URL') {
|
|
72
86
|
const env = ports.env ?? process.env;
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (result?.error || !(result?.ok === true || result?.status === 0)) throw new DbVerbError('
|
|
87
|
+
const result = ports.runPostgres ? ports.runPostgres(root, op) : (() => {
|
|
88
|
+
const connection = connectionOf(env, urlEnv);
|
|
89
|
+
const [command, args] = commandOf(op, connection.url);
|
|
90
|
+
return spawnSync(command, args, { cwd: root, encoding: 'utf8', env: connection.env, timeout: 120_000, windowsHide: true });
|
|
91
|
+
})();
|
|
92
|
+
if (result?.error || !(result?.ok === true || result?.status === 0)) throw new DbVerbError('database command failed; inspect psql privately');
|
|
79
93
|
return String(result.stdout ?? '');
|
|
80
94
|
}
|
|
81
95
|
|
|
82
|
-
|
|
96
|
+
/** A history change: `stamp` recorded as applied (named `name`) or reverted. */
|
|
97
|
+
export const repairOp = (stamp, status, name) => ({ kind: 'repair', stamp, status, ...(name ? { name } : {}) });
|
|
98
|
+
/** The printed form of a history change. */
|
|
99
|
+
export const repairLine = (op) => `history ${op.status} ${op.stamp}${op.name ? ` ${op.name}` : ''}`;
|
|
83
100
|
export const dbFailure = (verb, error, stderr) => { stderr(`starci app db ${verb}: ${error instanceof DbVerbError ? error.message : 'operation failed; database output was withheld'}\n`); return 2; };
|
|
84
101
|
|
|
85
102
|
function baselinePlan(root, opts, now) {
|
|
@@ -114,7 +131,7 @@ function baselineFiles(root) {
|
|
|
114
131
|
else if (entry.isFile() && /\.(?:sql|[cm]?[jt]sx?)$/.test(entry.name)) files.push(file);
|
|
115
132
|
}
|
|
116
133
|
};
|
|
117
|
-
for (const directory of ['
|
|
134
|
+
for (const directory of ['db', 'be/src', 'be/apps', 'src', 'apps']) visit(directory);
|
|
118
135
|
return files;
|
|
119
136
|
}
|
|
120
137
|
|
|
@@ -257,20 +274,21 @@ function metadataSql(metadata) {
|
|
|
257
274
|
/** Numeric CLI exit code, injectable IO/clock/CLI; neither files nor history change without --apply. */
|
|
258
275
|
export function dbBaselineMain(argv, { cwd = invocationDir(), stdout = (text) => process.stdout.write(text), stderr = (text) => process.stderr.write(text), now = () => new Date(), ...ports } = {}) {
|
|
259
276
|
try {
|
|
260
|
-
const opts = dbOptions(argv, ['cwd', 'stamp', 'apply', '
|
|
277
|
+
const opts = dbOptions(argv, ['cwd', 'stamp', 'apply', 'url-env']);
|
|
278
|
+
const urlEnv = opts['url-env'] ?? 'DATABASE_URL';
|
|
261
279
|
const root = dbAppRoot(cwd, opts);
|
|
262
280
|
const plan = baselinePlan(root, opts, now);
|
|
263
281
|
const files = baselineFiles(root);
|
|
264
282
|
if (baselineSourceFindings({ repoRoot: root, files, all: files, git: ports.git }).length) throw new DbVerbError('baseline sources and archives must match committed migration history');
|
|
265
283
|
const metadata = baselineMetadata(root, plan, files);
|
|
266
|
-
if (dbPlanMain(['--json',
|
|
267
|
-
const schema =
|
|
284
|
+
if (dbPlanMain(['--json', '--url-env', urlEnv], { ...ports, cwd: root, stdout: () => {}, stderr: () => {} }) !== 0) throw new DbVerbError('baseline requires a clean database plan with every migration already applied');
|
|
285
|
+
const schema = runDbPostgres(root, { kind: 'dump' }, ports, urlEnv);
|
|
268
286
|
if (!schema.trim() || baselineMarkers(schema).length || /\bvault\.(?:create_secret|update_secret)\s*\(|\bpassword\s+'[^']+'/i.test(schema)) throw new DbVerbError('schema dump is empty or contains unsupported secret/baseline statements');
|
|
269
|
-
const repairs = [...plan.active.map((entry) =>
|
|
287
|
+
const repairs = [...plan.active.map((entry) => repairOp(entry.stamp, 'reverted')), repairOp(plan.stamp, 'applied', 'baseline')];
|
|
270
288
|
stdout(`${opts.apply ? 'apply' : 'preview'} baseline ${plan.file}; archive ${plan.active.length} migrations\n`);
|
|
271
|
-
for (const
|
|
289
|
+
for (const op of repairs) stdout(`${repairLine(op)}\n`);
|
|
272
290
|
if (!opts.apply) return 0;
|
|
273
|
-
for (const
|
|
291
|
+
for (const op of repairs) runDbPostgres(root, op, ports, urlEnv);
|
|
274
292
|
fs.mkdirSync(path.join(root, MIGRATIONS_ARCHIVE_DIR), { recursive: true });
|
|
275
293
|
fs.writeFileSync(path.join(root, plan.file), `-- starci:baseline ${plan.last}\n${metadata.extensions}${metadata.roles}${schema.replace(/^\s+/, '')}\n${metadataSql(metadata)}`, { flag: 'wx' });
|
|
276
294
|
for (const entry of plan.active) fs.renameSync(path.join(root, entry.file), path.join(root, MIGRATIONS_ARCHIVE_DIR, entry.name));
|
|
@@ -1,27 +1,23 @@
|
|
|
1
|
-
// app db plan: compare migration-owned state without fetching commands
|
|
1
|
+
// app db plan: compare migration-owned state (history, pg_cron jobs, pgmq queues) without fetching commands.
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { sha256 } from '../../engine/digest.mjs';
|
|
5
5
|
import pg from 'libpg-query';
|
|
6
|
-
import { parse as parseToml } from 'smol-toml';
|
|
7
6
|
import { invocationDir } from '../lib/roots.mjs';
|
|
8
7
|
import { sqlStringParts, walkSqlAst as walk, sqlParser, sqlCallArgument as argument, sqlStringLiteral as literal } from '../lib/sql-ast.mjs';
|
|
9
|
-
import { DbVerbError, dbOptions, dbAppRoot, dbSqlFiles,
|
|
8
|
+
import { DbVerbError, dbOptions, dbAppRoot, dbSqlFiles, runDbPostgres, dbFailure } from './db-baseline-verb.mjs';
|
|
10
9
|
|
|
11
10
|
await pg.loadModule();
|
|
12
11
|
|
|
13
|
-
//
|
|
14
|
-
// Contract: supabase/cli apps/cli/src/commands/db/query/{query.command,query.handler}.ts at the pinned CLI tag.
|
|
12
|
+
// Each inventory is one read-only psql query whose rows come back as JSON (db-baseline-verb.mjs runDbPostgres).
|
|
15
13
|
const PRESENCE_SQL = `select
|
|
16
14
|
pg_catalog.to_regclass('supabase_migrations.schema_migrations') is not null as migrations,
|
|
17
15
|
pg_catalog.to_regclass('cron.job') is not null as cron,
|
|
18
|
-
pg_catalog.to_regclass('pgmq.meta') is not null as queues
|
|
19
|
-
pg_catalog.to_regclass('vault.secrets') is not null as vault;`;
|
|
16
|
+
pg_catalog.to_regclass('pgmq.meta') is not null as queues;`;
|
|
20
17
|
const INVENTORIES = {
|
|
21
18
|
migrations: 'select version from supabase_migrations.schema_migrations order by version;',
|
|
22
19
|
cron: 'select jobname as name, schedule, pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to(command, \'UTF8\')), \'hex\') as "commandHash", active from cron.job order by jobid;',
|
|
23
20
|
queues: 'select queue_name as name from pgmq.meta order by queue_name;',
|
|
24
|
-
vault: 'select name from vault.secrets order by name;',
|
|
25
21
|
};
|
|
26
22
|
const names = (nodes) => sqlStringParts(nodes).join('.');
|
|
27
23
|
const OWNERSHIP_CALLS = new Set(['pgmq.create', 'pgmq.drop_queue', 'cron.schedule', 'cron.unschedule', 'cron.schedule_in_database', 'cron.alter_job']);
|
|
@@ -99,91 +95,9 @@ function inventoryDo(state, node) {
|
|
|
99
95
|
for (const statement of statements) inventoryTree(state, statement);
|
|
100
96
|
}
|
|
101
97
|
|
|
102
|
-
function quotedEnd(text, start) {
|
|
103
|
-
const quote = text[start];
|
|
104
|
-
const delimiter = text.startsWith(quote.repeat(3), start) ? quote.repeat(3) : quote;
|
|
105
|
-
let cursor = start + delimiter.length;
|
|
106
|
-
while (cursor < text.length) {
|
|
107
|
-
if (quote === '"' && text[cursor] === '\\') { cursor += 2; continue; }
|
|
108
|
-
if (text.startsWith(delimiter, cursor)) {
|
|
109
|
-
let end = cursor + delimiter.length;
|
|
110
|
-
if (delimiter.length === 3) while (text[end] === quote) end += 1;
|
|
111
|
-
return end;
|
|
112
|
-
}
|
|
113
|
-
cursor += 1;
|
|
114
|
-
}
|
|
115
|
-
throw new DbVerbError('Supabase config contains an unterminated string');
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Advance over an assignment value without collecting, decoding or resolving its content.
|
|
119
|
-
function valueEnd(text, start) {
|
|
120
|
-
let cursor = start;
|
|
121
|
-
let depth = 0;
|
|
122
|
-
while (cursor < text.length) {
|
|
123
|
-
const character = text[cursor];
|
|
124
|
-
if (character === '"' || character === "'") { cursor = quotedEnd(text, cursor); continue; }
|
|
125
|
-
if (character === '#') {
|
|
126
|
-
cursor = text.indexOf('\n', cursor);
|
|
127
|
-
if (cursor < 0) return text.length;
|
|
128
|
-
if (depth === 0) return cursor + 1;
|
|
129
|
-
} else if (character === '[' || character === '{') depth += 1;
|
|
130
|
-
else if (character === ']' || character === '}') depth -= 1;
|
|
131
|
-
else if (character === '\n' && depth === 0) return cursor + 1;
|
|
132
|
-
cursor += 1;
|
|
133
|
-
}
|
|
134
|
-
return cursor;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function assignmentEnd(text, start) {
|
|
138
|
-
let cursor = start;
|
|
139
|
-
while (cursor < text.length && !['=', '\n', '#'].includes(text[cursor])) {
|
|
140
|
-
if (text[cursor] === '"' || text[cursor] === "'") cursor = quotedEnd(text, cursor);
|
|
141
|
-
else cursor += 1;
|
|
142
|
-
}
|
|
143
|
-
return cursor;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function vaultKeys(table, key) {
|
|
147
|
-
// The TOML parser sees only the table/key syntax and an invented empty value.
|
|
148
|
-
let metadata;
|
|
149
|
-
try { metadata = parseToml(`${table}\n${key} = ""`); }
|
|
150
|
-
catch { throw new DbVerbError('Supabase config keys could not be inventoried safely'); }
|
|
151
|
-
const vault = metadata.db?.vault ?? {};
|
|
152
|
-
if (typeof vault !== 'object' || Object.values(vault).some((value) => typeof value !== 'string')) throw new DbVerbError('Vault config requires individual named keys');
|
|
153
|
-
return Object.keys(vault);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function configToken(text, cursor, table) {
|
|
157
|
-
if (/\s/.test(text[cursor])) return { cursor: cursor + 1, table, keys: [] };
|
|
158
|
-
if (text[cursor] === '#' || text[cursor] === '[') {
|
|
159
|
-
const end = text.indexOf('\n', cursor);
|
|
160
|
-
const line = text.slice(cursor, end < 0 ? undefined : end);
|
|
161
|
-
return { cursor: end < 0 ? text.length : end + 1, table: text[cursor] === '[' ? line : table, keys: [] };
|
|
162
|
-
}
|
|
163
|
-
const end = assignmentEnd(text, cursor);
|
|
164
|
-
if (text[end] !== '=') throw new DbVerbError('Supabase config keys could not be inventoried safely');
|
|
165
|
-
return { cursor: valueEnd(text, end + 1), table, keys: vaultKeys(table, text.slice(cursor, end)) };
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function configVaultNames(root) {
|
|
169
|
-
const file = path.join(root, 'supabase/config.toml');
|
|
170
|
-
if (!fs.existsSync(file)) return new Map();
|
|
171
|
-
const text = fs.readFileSync(file, 'utf8');
|
|
172
|
-
const configured = new Map();
|
|
173
|
-
let token = { cursor: 0, table: '' };
|
|
174
|
-
while (token.cursor < text.length) {
|
|
175
|
-
token = configToken(text, token.cursor, token.table);
|
|
176
|
-
for (const name of token.keys) {
|
|
177
|
-
if (configured.has(name)) throw new DbVerbError('Vault config names must be unique');
|
|
178
|
-
configured.set(name, null);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
return configured;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
98
|
function repositoryState(root) {
|
|
185
99
|
const migrations = dbSqlFiles(root);
|
|
186
|
-
const state = { migrations: new Map(migrations.map((entry) => [entry.stamp, null])), cron: new Map(), queues: new Map()
|
|
100
|
+
const state = { migrations: new Map(migrations.map((entry) => [entry.stamp, null])), cron: new Map(), queues: new Map() };
|
|
187
101
|
if (state.migrations.size !== migrations.length) throw new DbVerbError('active migrations must have unique versions');
|
|
188
102
|
for (const entry of migrations) {
|
|
189
103
|
const tree = parseSql(fs.readFileSync(path.join(root, entry.file), 'utf8'));
|
|
@@ -197,22 +111,19 @@ function repositoryState(root) {
|
|
|
197
111
|
return state;
|
|
198
112
|
}
|
|
199
113
|
|
|
200
|
-
function queryRows(root, sql,
|
|
201
|
-
|
|
202
|
-
// Existing metadata is sufficient; never inspect the cache or credential files.
|
|
203
|
-
if (!local && !ports.runSupabase && !ports.run && !fs.existsSync(path.join(root, 'supabase/.temp/linked-project.json'))) throw new DbVerbError('link the existing project before running a read-only database plan');
|
|
204
|
-
const output = runDbSupabase(root, ['db', 'query', sql, local ? '--local' : '--linked', '--output', 'json', '--agent', 'no'], ports);
|
|
114
|
+
function queryRows(root, sql, urlEnv, ports) {
|
|
115
|
+
const output = runDbPostgres(root, { kind: 'query', sql }, ports, urlEnv);
|
|
205
116
|
let rows;
|
|
206
|
-
try { rows = JSON.parse(output); } catch { throw new DbVerbError('
|
|
207
|
-
if (!Array.isArray(rows) || rows.some((row) => !row || typeof row !== 'object' || Array.isArray(row))) throw new DbVerbError('
|
|
117
|
+
try { rows = JSON.parse(output); } catch { throw new DbVerbError('the database returned unreadable inventory; database output was withheld'); }
|
|
118
|
+
if (!Array.isArray(rows) || rows.some((row) => !row || typeof row !== 'object' || Array.isArray(row))) throw new DbVerbError('the database returned an invalid inventory; database output was withheld');
|
|
208
119
|
return rows;
|
|
209
120
|
}
|
|
210
121
|
|
|
211
|
-
function remoteState(root,
|
|
212
|
-
const rows = queryRows(root, PRESENCE_SQL,
|
|
213
|
-
if (rows.length !== 1 || Object.keys(INVENTORIES).some((key) => typeof rows[0][key] !== 'boolean')) throw new DbVerbError('
|
|
122
|
+
function remoteState(root, urlEnv, ports) {
|
|
123
|
+
const rows = queryRows(root, PRESENCE_SQL, urlEnv, ports);
|
|
124
|
+
if (rows.length !== 1 || Object.keys(INVENTORIES).some((key) => typeof rows[0][key] !== 'boolean')) throw new DbVerbError('the database returned invalid inventory availability');
|
|
214
125
|
const state = {};
|
|
215
|
-
for (const [key, sql] of Object.entries(INVENTORIES)) state[key] = rows[0][key] ? queryRows(root, sql,
|
|
126
|
+
for (const [key, sql] of Object.entries(INVENTORIES)) state[key] = rows[0][key] ? queryRows(root, sql, urlEnv, ports) : [];
|
|
216
127
|
return state;
|
|
217
128
|
}
|
|
218
129
|
|
|
@@ -222,8 +133,8 @@ function compare(expected, actual, kind) {
|
|
|
222
133
|
let changed = 0;
|
|
223
134
|
for (const row of actual) {
|
|
224
135
|
const key = kind === 'migrations' ? row.version : row.name;
|
|
225
|
-
if (key !== null && (typeof key !== 'string' || key.length === 0)) throw new DbVerbError('
|
|
226
|
-
if (kind === 'cron' && (typeof row.schedule !== 'string' || !/^[a-f0-9]{64}$/.test(row.commandHash ?? '') || typeof row.active !== 'boolean')) throw new DbVerbError('
|
|
136
|
+
if (key !== null && (typeof key !== 'string' || key.length === 0)) throw new DbVerbError('the database returned an invalid inventory identifier');
|
|
137
|
+
if (kind === 'cron' && (typeof row.schedule !== 'string' || !/^[a-f0-9]{64}$/.test(row.commandHash ?? '') || typeof row.active !== 'boolean')) throw new DbVerbError('the database returned invalid cron metadata');
|
|
227
138
|
if (!unmatched.delete(key)) unexpected += 1;
|
|
228
139
|
else if (kind === 'cron') {
|
|
229
140
|
const desired = expected.get(key);
|
|
@@ -236,10 +147,10 @@ function compare(expected, actual, kind) {
|
|
|
236
147
|
/** Numeric exit codes: 0 equal, 1 drift, 2 invalid/failed; all IO and CLI calls are injectable. */
|
|
237
148
|
export function dbPlanMain(argv, { cwd = invocationDir(), stdout = (text) => process.stdout.write(text), stderr = (text) => process.stderr.write(text), ...ports } = {}) {
|
|
238
149
|
try {
|
|
239
|
-
const opts = dbOptions(argv, ['cwd', '
|
|
150
|
+
const opts = dbOptions(argv, ['cwd', 'url-env', 'json', 'apply']);
|
|
240
151
|
const root = dbAppRoot(cwd, opts);
|
|
241
152
|
const expected = repositoryState(root);
|
|
242
|
-
const actual = remoteState(root, opts
|
|
153
|
+
const actual = remoteState(root, opts['url-env'] ?? 'DATABASE_URL', ports);
|
|
243
154
|
const summaries = Object.fromEntries(Object.keys(INVENTORIES).map((kind) => [kind, compare(expected[kind], actual[kind], kind)]));
|
|
244
155
|
const drift = Object.values(summaries).some((item) => item.missing || item.unexpected || item.changed);
|
|
245
156
|
// --apply is accepted for shared verb ergonomics; a plan never mutates history, queues or jobs.
|
|
@@ -5,7 +5,7 @@ import { parseYaml } from '../../engine/yaml.mjs';
|
|
|
5
5
|
import { invocationDir } from '../lib/roots.mjs';
|
|
6
6
|
import { migrationStamp } from './rules/database-migrations.mjs';
|
|
7
7
|
import { MIGRATIONS_DIR } from './rules/database-constants.mjs';
|
|
8
|
-
import { DbVerbError, dbOptions, dbAppRoot, dbSqlFiles,
|
|
8
|
+
import { DbVerbError, dbOptions, dbAppRoot, dbSqlFiles, runDbPostgres, repairOp, repairLine, dbFailure } from './db-baseline-verb.mjs';
|
|
9
9
|
|
|
10
10
|
/** The map value of one old version: a new version, or { file, to, applied } for the colliding-versions case. */
|
|
11
11
|
function mapEntry(old, raw) {
|
|
@@ -39,16 +39,16 @@ function sourceOf(entry, active) {
|
|
|
39
39
|
* of the file that ran). No row means nothing ran; a row named for the other file means the one that stays ran. A history that cannot be read is
|
|
40
40
|
* not guessed: the entry has to say `applied`.
|
|
41
41
|
*/
|
|
42
|
-
function recordedAsApplied(root, source, old,
|
|
42
|
+
function recordedAsApplied(root, source, old, urlEnv, ports) {
|
|
43
43
|
const unreadable = new DbVerbError(`version ${old} is shared by several migrations and the history could not be read; say which one ran with "${old}": { file: <name>, applied: true|false }`);
|
|
44
44
|
let rows;
|
|
45
|
-
try { rows = JSON.parse(
|
|
45
|
+
try { rows = JSON.parse(runDbPostgres(root, { kind: 'query', sql: `select name from supabase_migrations.schema_migrations where version = '${old}'` }, ports, urlEnv)); } catch { throw unreadable; }
|
|
46
46
|
if (!Array.isArray(rows) || (rows.length > 0 && typeof rows[0]?.name !== 'string')) throw unreadable;
|
|
47
47
|
return rows.length > 0 && rows[0].name === source.name.slice(15).replace(/\.sql$/, '');
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
/** YAML maps use quoted UTC stamps as keys and a new version, or { file, to?, applied? }, as values (or a versions: mapping). */
|
|
51
|
-
function restampPlan(root, mapFile, {
|
|
51
|
+
function restampPlan(root, mapFile, { urlEnv = 'DATABASE_URL', ports = {} } = {}) {
|
|
52
52
|
if (!mapFile) throw new DbVerbError('restamp requires --map <restamp.yaml>');
|
|
53
53
|
let document;
|
|
54
54
|
try { document = parseYaml(fs.readFileSync(path.resolve(root, mapFile), 'utf8')); }
|
|
@@ -67,7 +67,7 @@ function restampPlan(root, mapFile, { local = false, ports = {} } = {}) {
|
|
|
67
67
|
if (occupied.has(next) || targets.has(next)) throw new DbVerbError('restamp targets must be unique unoccupied versions');
|
|
68
68
|
targets.add(next);
|
|
69
69
|
// a version two files share ran for at most one of them: the history says which, unless the entry does
|
|
70
|
-
const applied = entry.applied ?? (shared ? recordedAsApplied(root, source, old,
|
|
70
|
+
const applied = entry.applied ?? (shared ? recordedAsApplied(root, source, old, urlEnv, ports) : true);
|
|
71
71
|
return { old, next, source: source.file, target: `${MIGRATIONS_DIR}/${source.name.replace(old, next)}`, applied };
|
|
72
72
|
});
|
|
73
73
|
const moved = new Map(steps.map((step) => [step.source, step.next]));
|
|
@@ -79,16 +79,17 @@ function restampPlan(root, mapFile, { local = false, ports = {} } = {}) {
|
|
|
79
79
|
/** The complete map is validated before any repair or file mutation. */
|
|
80
80
|
export function dbRestampMain(argv, { cwd = invocationDir(), stdout = (text) => process.stdout.write(text), stderr = (text) => process.stderr.write(text), ...ports } = {}) {
|
|
81
81
|
try {
|
|
82
|
-
const opts = dbOptions(argv, ['cwd', 'map', 'apply', '
|
|
82
|
+
const opts = dbOptions(argv, ['cwd', 'map', 'apply', 'url-env']);
|
|
83
|
+
const urlEnv = opts['url-env'] ?? 'DATABASE_URL';
|
|
83
84
|
const root = dbAppRoot(cwd, opts);
|
|
84
|
-
const steps = restampPlan(root, opts.map, {
|
|
85
|
-
const repairs = steps.filter((step) => step.applied).flatMap((step) => [
|
|
85
|
+
const steps = restampPlan(root, opts.map, { urlEnv, ports });
|
|
86
|
+
const repairs = steps.filter((step) => step.applied).flatMap((step) => [repairOp(step.old, 'reverted'), repairOp(step.next, 'applied', step.target.split('/').at(-1).slice(15).replace(/\.sql$/, ''))]);
|
|
86
87
|
stdout(`${opts.apply ? 'apply' : 'preview'} restamp ${steps.length} migrations\n`);
|
|
87
88
|
for (const step of steps) stdout(`${step.source} -> ${step.target}\n`);
|
|
88
|
-
for (const
|
|
89
|
+
for (const op of repairs) stdout(`${repairLine(op)}\n`);
|
|
89
90
|
if (!opts.apply) return 0;
|
|
90
91
|
// CLI failures never echo credentials and leave the local files intact for an explicit retry.
|
|
91
|
-
for (const
|
|
92
|
+
for (const op of repairs) runDbPostgres(root, op, ports, urlEnv);
|
|
92
93
|
for (const step of steps) {
|
|
93
94
|
const source = path.join(root, step.source);
|
|
94
95
|
const text = fs.readFileSync(source, 'utf8').replace(/^\s*--\s*starci:restamp\s+\d{14}\s+\d{14}\s*\r?\n/, '');
|
|
@@ -2,27 +2,23 @@
|
|
|
2
2
|
// against a loaded manifest remain in slots.mjs; this module mirrors modules/schemas/hfs-repo.schema.yaml only.
|
|
3
3
|
import { isPlainObject } from '../../engine/plain-object.mjs';
|
|
4
4
|
import { APP_KIND, connectionShapeProblems, NAME, PACKAGE_NAME, RUNTIME_KIND, SLOT_ID } from './manifest-shape.mjs';
|
|
5
|
+
import { dbFunctionsOk } from './rule-params-shape.mjs';
|
|
5
6
|
import { kindShapeProblems, patternShapeProblems } from './declaration-slots.mjs';
|
|
6
7
|
import { byCodeUnit } from '../lib/list.mjs';
|
|
7
8
|
|
|
8
|
-
/** Shape problems of the optional hfs.json `
|
|
9
|
-
const
|
|
9
|
+
/** Shape problems of the optional hfs.json `db` block of a lite app: the environment variable naming its database, its image, forced RLS and its application role. */
|
|
10
|
+
const isStringList = (value) => Array.isArray(value) && value.every((entry) => typeof entry === 'string' && entry.trim());
|
|
10
11
|
|
|
11
|
-
function
|
|
12
|
-
const bad = [];
|
|
13
|
-
if (block.enableSignup !== undefined && typeof block.enableSignup !== 'boolean') bad.push('supabase.enableSignup must be true or false');
|
|
14
|
-
if (block.jwtExpiry !== undefined && !(Number.isInteger(block.jwtExpiry) && block.jwtExpiry > 0 && block.jwtExpiry <= 3600)) bad.push('supabase.jwtExpiry must be an integer of seconds, 3600 at most');
|
|
15
|
-
if (block.siteUrl !== undefined && (typeof block.siteUrl !== 'string' || !block.siteUrl.trim())) bad.push('supabase.siteUrl must be a URL string');
|
|
16
|
-
for (const key of ['redirectUrls', 'forceRls']) if (block[key] !== undefined && !isSupabaseStringList(block[key])) bad.push(`supabase.${key} must be a list of strings`);
|
|
17
|
-
return bad;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function supabaseBlockProblems(block) {
|
|
12
|
+
function dbBlockProblems(block) {
|
|
21
13
|
if (block === undefined) return [];
|
|
22
|
-
if (!isPlainObject(block)) return ['
|
|
14
|
+
if (!isPlainObject(block)) return ['db must be an object'];
|
|
23
15
|
const bad = [];
|
|
24
|
-
for (const key of Object.keys(block)) if (!['
|
|
25
|
-
|
|
16
|
+
for (const key of Object.keys(block)) if (!['urlEnv', 'image', 'forceRls', 'forceRlsExemptions', 'appRole'].includes(key)) bad.push(`db has unknown key ${key}`);
|
|
17
|
+
if (block.urlEnv !== undefined && (typeof block.urlEnv !== 'string' || !/^[A-Z][A-Z0-9_]*$/.test(block.urlEnv))) bad.push('db.urlEnv must name an UPPER_CASE environment variable');
|
|
18
|
+
if (block.image !== undefined && (typeof block.image !== 'string' || !/@sha256:[0-9a-f]{64}$/.test(block.image))) bad.push('db.image must be a digest-pinned image reference');
|
|
19
|
+
if (block.forceRls !== undefined && !isStringList(block.forceRls)) bad.push('db.forceRls must be a list of strings');
|
|
20
|
+
if (block.appRole !== undefined && (typeof block.appRole !== 'string' || !/^[a-z][a-z0-9_]*$/.test(block.appRole))) bad.push('db.appRole must name a role');
|
|
21
|
+
if (block.forceRlsExemptions !== undefined && !Array.isArray(block.forceRlsExemptions)) bad.push('db.forceRlsExemptions must be a list of { table, reason }');
|
|
26
22
|
return bad;
|
|
27
23
|
}
|
|
28
24
|
|
|
@@ -95,7 +91,8 @@ function declarationSideProblems(side, value, names) {
|
|
|
95
91
|
const bad = [];
|
|
96
92
|
const at = `sides.${side}`;
|
|
97
93
|
if (!isPlainObject(value)) return [`${at} must be an object`];
|
|
98
|
-
for (const key of Object.keys(value)) if (!['apps', 'optionalSlots', 'patterns', 'kinds', 'connections', 'reads', 'extraPackages', 'grammar', 'backing', 'queueOutbox'].includes(key)) bad.push(`${at} has unknown key ${key}`);
|
|
94
|
+
for (const key of Object.keys(value)) if (!['apps', 'optionalSlots', 'patterns', 'kinds', 'connections', 'reads', 'extraPackages', 'grammar', 'backing', 'queueOutbox', 'dbFunctions'].includes(key)) bad.push(`${at} has unknown key ${key}`);
|
|
95
|
+
if (value.dbFunctions !== undefined && (side !== 'be' || !dbFunctionsOk(value.dbFunctions))) bad.push(`${at}.dbFunctions belongs to the be side and lists {name, reason} database functions with unique names`);
|
|
99
96
|
bad.push(...appShapeProblems(value.apps, at, side, names));
|
|
100
97
|
if (isBadUniqueList(value.optionalSlots, (slot) => SLOT_ID.test(String(slot)))) bad.push(`${at}.optionalSlots must be a unique list of slot ids`);
|
|
101
98
|
if (value.backing !== undefined && side !== 'be') bad.push('backing belongs to the be side');
|
|
@@ -127,13 +124,13 @@ function observeBlockProblems(observe) {
|
|
|
127
124
|
return bad;
|
|
128
125
|
}
|
|
129
126
|
|
|
130
|
-
const DECLARATION_KEYS = new Set(['hfs', 'kind', 'project', 'edition', '
|
|
127
|
+
const DECLARATION_KEYS = new Set(['hfs', 'kind', 'project', 'edition', 'db', 'sides', 'browser', 'lexicon', 'observe']);
|
|
131
128
|
const RUNTIME_DECLARATION_KEYS = new Set(['hfs', 'kind', 'project']);
|
|
132
129
|
|
|
133
130
|
function productDeclarationProblems(declaration, profiles) {
|
|
134
131
|
const bad = [];
|
|
135
132
|
for (const key of Object.keys(declaration)) if (!DECLARATION_KEYS.has(key)) bad.push(`unknown key ${key}`);
|
|
136
|
-
bad.push(...
|
|
133
|
+
bad.push(...dbBlockProblems(declaration.db), ...lexiconBlockProblems(declaration.lexicon), ...observeBlockProblems(declaration.observe));
|
|
137
134
|
if (declaration.browser !== undefined && declaration.browser !== true) bad.push('browser is `true` when the app has a browser journey (slot app.browser), and is left out otherwise');
|
|
138
135
|
if (declaration.kind !== APP_KIND) bad.push(`kind must be ${APP_KIND} (a product is one app repository with a be and an fe side) or ${RUNTIME_KIND} (the StarCi runtime repository)`);
|
|
139
136
|
if (!isPlainObject(declaration.sides) || Object.keys(declaration.sides).sort(byCodeUnit).join() !== profiles.join()) { bad.push('sides must declare exactly be and fe'); return bad; }
|
|
@@ -15,12 +15,12 @@ const ENV_PREFIX =/^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$/;
|
|
|
15
15
|
const CONTEXT_ISOLATIONS = Object.freeze(["database", "schema"]);
|
|
16
16
|
/** The app kinds that may own a context: the services, never the migrate or cli apps that only run migrations. */
|
|
17
17
|
const CONTEXT_OWNER_KINDS = Object.freeze(["api", "worker"]);
|
|
18
|
-
/** The database
|
|
19
|
-
const CONNECTION_PROVIDERS = Object.freeze(["postgres"
|
|
18
|
+
/** The database engine a connection runs on. */
|
|
19
|
+
const CONNECTION_PROVIDERS = Object.freeze(["postgres"]);
|
|
20
20
|
/** The editions an app repository may declare in hfs.json (`full` when absent): the filter slots.mjs applies. */
|
|
21
21
|
export const EDITIONS = Object.freeze(["full", "lite"]);
|
|
22
|
-
/** What decides where schema changes live (ruleParams.<side>.schemaAuthority): TypeORM migrations under persistence/, or the
|
|
23
|
-
export const SCHEMA_AUTHORITIES = Object.freeze(["typeorm", "
|
|
22
|
+
/** What decides where schema changes live (ruleParams.<side>.schemaAuthority): TypeORM migrations under persistence/, or the SQL migration tree db/migrations. */
|
|
23
|
+
export const SCHEMA_AUTHORITIES = Object.freeze(["typeorm", "sql"]);
|
|
24
24
|
/**
|
|
25
25
|
* The shape problems of the `connections` of one side: a list of {name, envPrefix, owner, isolation}, the owner an api or worker app
|
|
26
26
|
* of the same side (a connection is a bounded context and one service owns it), isolation `database` or `schema`.
|
|
@@ -87,7 +87,9 @@ export const MANIFEST_KINDS = [APP_KIND, RUNTIME_KIND];
|
|
|
87
87
|
export const manifestKind = (m) => (isPlainObject(m) && m.kind !== undefined ? m.kind : APP_KIND);
|
|
88
88
|
const strList = (v) => stringList(v);
|
|
89
89
|
|
|
90
|
-
const APP_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'appKind', 'minInstances', 'requiredWhen', 'requiredInstances', 'requires', 'pattern', 'trigger', 'allows', 'forbids', 'layers', 'kinds', 'roles', 'composedBy', 'budget', 'managedBy', 'liteManagedBy', 'rules', 'goesTo', 'why', 'since', 'perConnection', 'parent', 'editions', 'editionWhy', 'litePresence', 'lite', 'provider', 'coverage', 'entries', 'outcomeHome', 'templateNa', 'exemplifiedBy', 'rewrittenBy'];
|
|
90
|
+
const APP_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'appKind', 'minInstances', 'requiredWhen', 'requiredInstances', 'requires', 'pattern', 'trigger', 'allows', 'forbids', 'layers', 'kinds', 'roles', 'composedBy', 'budget', 'managedBy', 'liteManagedBy', 'rules', 'goesTo', 'why', 'since', 'perConnection', 'parent', 'editions', 'editionWhy', 'litePresence', 'lite', 'provider', 'coverage', 'entries', 'outcomeHome', 'templateNa', 'exemplifiedBy', 'role', 'importedBy', 'rewrittenBy'];
|
|
91
|
+
/** The roles a file slot may play that a rule reads (`role`): the entity registry of persistence.md BE-PERSISTENCE-3 (architecture/entity-registry.mjs). */
|
|
92
|
+
const SLOT_ROLES = ['entity-registry'];
|
|
91
93
|
/** The fields a slot's `lite` overlay may hold: the same keys it would carry in the slot body, resolved under edition lite. */
|
|
92
94
|
const LITE_OVERLAY_KEYS = ['path', 'requires', 'allows', 'forbids', 'minInstances', 'requiredInstances', 'why'];
|
|
93
95
|
/** A runtime slot has no app kind, side composition, layer or managed template; it may name the generator of a generated copy. */
|
|
@@ -250,6 +252,8 @@ function slotEntryProblems(slot, at) {
|
|
|
250
252
|
else if (Array.isArray(slot.allows) && slot.entries.some((entry) => !slot.allows.includes(entry))) bad.push(`${at}: every entry must be one of the names the slot allows`);
|
|
251
253
|
}
|
|
252
254
|
if (slot.outcomeHome !== undefined && !(slot.outcomeHome === true && typeof slot.path === 'string' && slot.path && !slot.path.endsWith('/'))) bad.push(`${at}: outcomeHome is true and belongs to a file slot (the one file that declares the repository's Outcome union)`);
|
|
255
|
+
if (slot.role !== undefined && !(SLOT_ROLES.includes(slot.role) && typeof slot.path === 'string' && slot.path && !slot.path.endsWith('/'))) bad.push(`${at}: role is one of ${SLOT_ROLES.join(', ')} and belongs to a file slot`);
|
|
256
|
+
if (slot.importedBy !== undefined && !(slot.role !== undefined && strList(slot.importedBy) && slot.importedBy.length && new Set(slot.importedBy).size === slot.importedBy.length && slot.importedBy.every(relPath))) bad.push(`${at}: importedBy is a non-empty list of unique relative path patterns of the only files that import the slot's file by deep path, on a slot with a role`);
|
|
253
257
|
return bad;
|
|
254
258
|
}
|
|
255
259
|
|