@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/emit/db-types.mjs
DELETED
|
@@ -1,271 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `supabase/types/database.types.ts` (slot `app.supabase.types`): the Database types the Supabase CLI generates from an app's
|
|
3
|
-
* local stack, written by `npm run contract:emit` (`starci app emit`) beside the be contract snapshots and judged for drift
|
|
4
|
-
* by DB_TYPES_DRIFT (L09, scripts/hfs/rules/database.mjs) - the rule compares the committed file with the text `emitTypes()`
|
|
5
|
-
* produces, and `dbTypesEmitter` below is that callback.
|
|
6
|
-
*
|
|
7
|
-
* The text is `supabase gen types typescript --local` run at the app root against the running local stack, narrowed to the
|
|
8
|
-
* schemas `supabase/config.toml` declares under [api]; the CLI reaches Docker, so nothing here runs inside a check that has
|
|
9
|
-
* no stack. `run` is the command runner - `(file, args, {cwd})` answering the spawnSync result `{status, stdout, stderr}` -
|
|
10
|
-
* the default spawns the `supabase` binary with an argument array, never a shell string and never printing env; a spec's fake
|
|
11
|
-
* keeps Docker out of the suite. Before generation, `migration list --local` must prove that the database's applied versions
|
|
12
|
-
* equal the migration filename stamps (HFS_EMIT_DB_TYPES_STALE otherwise). Unreadable history or failed generation throws HFS_EMIT_DB_TYPES_FAILED.
|
|
13
|
-
*/
|
|
14
|
-
import { createHash } from 'node:crypto';
|
|
15
|
-
import { spawnSync } from 'node:child_process';
|
|
16
|
-
import fs from 'node:fs';
|
|
17
|
-
import path from 'node:path';
|
|
18
|
-
import { parse as parseToml } from 'smol-toml';
|
|
19
|
-
import { bundledPins } from '../runtime/scripts/hfs/canon-pins.mjs';
|
|
20
|
-
|
|
21
|
-
/** The repository-relative path of the generated types (the path of slot `app.supabase.types`). */
|
|
22
|
-
export const dbTypesPath = 'supabase/types/database.types.ts';
|
|
23
|
-
|
|
24
|
-
/** The folder of the app's migrations, and the first line of the committed types: the digest of the migrations they were generated from. */
|
|
25
|
-
const MIGRATIONS_DIR = 'supabase/migrations';
|
|
26
|
-
const TYPES_STAMP = '// migrations-sha256: ';
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* The sha256 of the app's migrations, hex: every `supabase/migrations/*.sql` by name, each as name, NUL, its text with LF line ends,
|
|
30
|
-
* NUL. The offline codegen hook of a lite app (templates/app/codegen-lite) computes the same digest and refuses types stamped with another.
|
|
31
|
-
*/
|
|
32
|
-
function migrationsDigest(root) {
|
|
33
|
-
const dir = path.join(root, MIGRATIONS_DIR);
|
|
34
|
-
const hash = createHash('sha256');
|
|
35
|
-
for (const name of (fs.existsSync(dir) ? fs.readdirSync(dir) : []).filter((file) => file.endsWith('.sql')).sort()) {
|
|
36
|
-
hash.update(`${name}\0${fs.readFileSync(path.join(dir, name), 'utf8').replace(/\r\n/g, '\n')}\0`);
|
|
37
|
-
}
|
|
38
|
-
return hash.digest('hex');
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** The slot that owns the generated types; enabled by a connection declaring provider supabase. */
|
|
42
|
-
const DB_TYPES_SLOT = 'app.supabase.types';
|
|
43
|
-
|
|
44
|
-
/** The config file whose [api] schemas narrow the generation. */
|
|
45
|
-
const CONFIG_FILE = 'supabase/config.toml';
|
|
46
|
-
const SUPABASE_CLI = 'supabase';
|
|
47
|
-
|
|
48
|
-
/** A types emit refuses unreadable history or failed generation with HFS_EMIT_DB_TYPES_FAILED, and unequal applied versions with HFS_EMIT_DB_TYPES_STALE. */
|
|
49
|
-
class DbTypesError extends Error {
|
|
50
|
-
constructor(message, details = {}, code = 'HFS_EMIT_DB_TYPES_FAILED') {
|
|
51
|
-
super(`${code}: ${message}`);
|
|
52
|
-
this.name = 'DbTypesError';
|
|
53
|
-
this.code = code;
|
|
54
|
-
this.details = details;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const spawn = (file, args, options = {}) => spawnSync(file, args, { encoding: 'utf8', windowsHide: true, maxBuffer: 64 * 1024 * 1024, ...options });
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* The one spawn: an argument array, a hidden window, a bound buffer, never a shell. Specs inject a fake of the same shape. The
|
|
62
|
-
* `supabase` binary is the one on PATH; a machine without it runs the CLI at the canon pin through npx (the pin of knowledge/hfs/canon-pins.yaml).
|
|
63
|
-
*/
|
|
64
|
-
const withNpxFallback = (run, platform) => (file, args, options = {}) => {
|
|
65
|
-
const first = run(file, args, options);
|
|
66
|
-
if (file !== SUPABASE_CLI || first.error?.code !== 'ENOENT') return first;
|
|
67
|
-
const version = bundledPins()[SUPABASE_CLI]?.version;
|
|
68
|
-
if (!version) return first;
|
|
69
|
-
return platform === 'win32'
|
|
70
|
-
? run('cmd.exe', ['/d', '/s', '/c', 'npx', '--yes', `${SUPABASE_CLI}@${version}`, ...args], options)
|
|
71
|
-
: run('npx', ['--yes', `${SUPABASE_CLI}@${version}`, ...args], options);
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
/** The first meaningful line of a failure output, for the error a human acts on. */
|
|
75
|
-
const firstLine = (text) => String(text ?? '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean)[0];
|
|
76
|
-
|
|
77
|
-
const START_TAIL_LINES = 12;
|
|
78
|
-
const START_LINE_CHARS = 300;
|
|
79
|
-
/** The last lines (bounded) of a failed `supabase start`: its cause (42501 permission denied to alter role) sits after the first progress lines, so the first line alone hid it. */
|
|
80
|
-
const startDetail = (started) => {
|
|
81
|
-
const text = [started?.stderr, started?.stdout, started?.error?.message ?? started?.error].map((part) => String(part ?? '')).find((part) => part.trim()) ?? '';
|
|
82
|
-
const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).map((line) => (line.length > START_LINE_CHARS ? `${line.slice(0, START_LINE_CHARS)}...` : line));
|
|
83
|
-
return lines.length ? lines.slice(-START_TAIL_LINES).join(' | ') : 'no output';
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* The schemas `supabase/config.toml` exposes under [api] (`[]` when the file or the list is absent - the CLI then reads its
|
|
88
|
-
* own defaults). A config that does not parse is a refusal: L08 reads the same file and the emit must not guess.
|
|
89
|
-
*/
|
|
90
|
-
function apiSchemas(root) {
|
|
91
|
-
const file = path.join(root, CONFIG_FILE);
|
|
92
|
-
if (!fs.existsSync(file)) return [];
|
|
93
|
-
let toml;
|
|
94
|
-
try { toml = parseToml(fs.readFileSync(file, 'utf8')); } catch (error) {
|
|
95
|
-
throw new DbTypesError(`${CONFIG_FILE} is not readable valid TOML (${firstLine(error?.message ?? error) ?? 'unreadable'}); the types scope comes from its [api] schemas`, { file: CONFIG_FILE });
|
|
96
|
-
}
|
|
97
|
-
return (toml?.api?.schemas ?? []).filter((schema) => typeof schema === 'string');
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/** CLI JSON and table rows use the remote field for the database history even with --local; local only describes files. */
|
|
101
|
-
function historyVersion(value) {
|
|
102
|
-
if (value == null || value === '') return null;
|
|
103
|
-
if (typeof value !== 'string' || !/^\d+$/.test(value)) {
|
|
104
|
-
throw new DbTypesError('the local migration history contains an unreadable row', { file: dbTypesPath });
|
|
105
|
-
}
|
|
106
|
-
return value;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/** JSON must explicitly carry a migrations array; an empty array proves an empty database history. */
|
|
110
|
-
function jsonAppliedVersions(text) {
|
|
111
|
-
let history;
|
|
112
|
-
try { history = JSON.parse(text); } catch {
|
|
113
|
-
throw new DbTypesError('the local migration history contains unreadable JSON', { file: dbTypesPath });
|
|
114
|
-
}
|
|
115
|
-
if (!Array.isArray(history?.migrations)) {
|
|
116
|
-
throw new DbTypesError('the local migration history has no migrations array', { file: dbTypesPath });
|
|
117
|
-
}
|
|
118
|
-
const versions = new Set();
|
|
119
|
-
for (const row of history.migrations) {
|
|
120
|
-
if (!row || !Object.hasOwn(row, 'local') || !Object.hasOwn(row, 'remote')) {
|
|
121
|
-
throw new DbTypesError('the local migration history contains an unreadable row', { file: dbTypesPath });
|
|
122
|
-
}
|
|
123
|
-
historyVersion(row.local);
|
|
124
|
-
const applied = historyVersion(row.remote);
|
|
125
|
-
if (applied) versions.add(applied);
|
|
126
|
-
}
|
|
127
|
-
return versions;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** Optional outer borders do not add columns; pretty output may wrap each table cell in Markdown backticks. */
|
|
131
|
-
const historyCells = (line, bordered) => {
|
|
132
|
-
const row = bordered ? line.trim().replace(/^\|/, '').replace(/\|$/, '') : line;
|
|
133
|
-
return row.split('|').map((cell) => cell.trim().replace(/^`(.*)`$/, '$1'));
|
|
134
|
-
};
|
|
135
|
-
|
|
136
|
-
/** A recognized header, including a header without rows, proves a table history; malformed rows still refuse. */
|
|
137
|
-
function tableAppliedVersions(lines, header) {
|
|
138
|
-
const versions = new Set();
|
|
139
|
-
const bordered = /^\s*\|/.test(lines[header]);
|
|
140
|
-
const width = historyCells(lines[header], bordered).length;
|
|
141
|
-
for (const line of lines.slice(header + 1)) {
|
|
142
|
-
if (/^[\s|+-]+$/.test(line)) continue;
|
|
143
|
-
if (!line.includes('|')) continue;
|
|
144
|
-
const cells = historyCells(line, bordered);
|
|
145
|
-
if (cells.length !== width) {
|
|
146
|
-
throw new DbTypesError('the local migration history contains an unreadable row', { file: dbTypesPath });
|
|
147
|
-
}
|
|
148
|
-
historyVersion(cells[0]);
|
|
149
|
-
const applied = historyVersion(cells[1]);
|
|
150
|
-
if (applied) versions.add(applied);
|
|
151
|
-
}
|
|
152
|
-
return versions;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/** Read either CLI stream, stripping terminal colors, and require an explicit JSON history or Local | Remote [| Time] header. */
|
|
156
|
-
function appliedVersions(result) {
|
|
157
|
-
for (const output of [result.stdout, result.stderr]) {
|
|
158
|
-
const text = String(output ?? '').replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').replaceAll('\u2502', '|').trim();
|
|
159
|
-
const lines = text.split(/\r?\n/);
|
|
160
|
-
const json = lines.findIndex((line) => line.trimStart().startsWith('{'));
|
|
161
|
-
if (json >= 0) return jsonAppliedVersions(lines.slice(json).join('\n'));
|
|
162
|
-
const header = lines.findIndex((line) => /^\s*\|?\s*Local\s*\|\s*Remote\s*(?:\|\s*Time(?:\s*\(UTC\))?\s*)?\|?\s*$/i.test(line));
|
|
163
|
-
if (header >= 0) return tableAppliedVersions(lines, header);
|
|
164
|
-
}
|
|
165
|
-
throw new DbTypesError('the local migration history is unreadable; expected migrations JSON or the Local | Remote migration list', { file: dbTypesPath });
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/** Every applied local version must equal a migration filename stamp, including gaps and extra versions even when the heads match. */
|
|
169
|
-
function requireCurrentDatabase(root, run) {
|
|
170
|
-
const dir = path.join(root, MIGRATIONS_DIR);
|
|
171
|
-
const expected = new Set();
|
|
172
|
-
for (const name of (fs.existsSync(dir) ? fs.readdirSync(dir) : []).filter((file) => file.endsWith('.sql'))) {
|
|
173
|
-
const stamp = /^(\d+)(?:_|\.sql$)/.exec(name)?.[1];
|
|
174
|
-
if (!stamp) throw new DbTypesError(`${MIGRATIONS_DIR} contains a SQL file without a migration version`, { file: dbTypesPath });
|
|
175
|
-
expected.add(stamp);
|
|
176
|
-
}
|
|
177
|
-
let result;
|
|
178
|
-
try { result = run(SUPABASE_CLI, ['migration', 'list', '--local'], { cwd: root }); } catch {
|
|
179
|
-
throw new DbTypesError('supabase migration list --local could not run; the local migration history must be readable before emitting types', { file: dbTypesPath });
|
|
180
|
-
}
|
|
181
|
-
if (result?.error || result?.signal || result?.status !== 0) {
|
|
182
|
-
throw new DbTypesError('supabase migration list --local failed; the local migration history must be readable before emitting types', { file: dbTypesPath });
|
|
183
|
-
}
|
|
184
|
-
const applied = appliedVersions(result);
|
|
185
|
-
if (applied.size !== expected.size || [...expected].some((version) => !applied.has(version))) {
|
|
186
|
-
const head = (versions) => [...versions].sort().at(-1) ?? 'none';
|
|
187
|
-
throw new DbTypesError(`the local database is at ${head(applied)}, the migrations folder at ${head(expected)}; inspect "starci app db plan --local", preview "starci app db migrate --local", apply the reviewed local plan with --execute and STARCI_DB_ARMED=1, then run "starci app emit"`, { file: dbTypesPath }, 'HFS_EMIT_DB_TYPES_STALE');
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* The freshly generated text of `dbTypesPath` for the app at `root`: `supabase gen types typescript --local` (the running
|
|
193
|
-
* local stack), narrowed to the [api] schemas of supabase/config.toml. A failing CLI, a spawn error and an empty answer all
|
|
194
|
-
* raise DbTypesError; the first stderr line names the cause.
|
|
195
|
-
*/
|
|
196
|
-
function emitDbTypesWithRunner({ root, run }) {
|
|
197
|
-
const schemas = apiSchemas(root);
|
|
198
|
-
requireCurrentDatabase(root, run);
|
|
199
|
-
const args = ['gen', 'types', 'typescript', '--local', ...(schemas.length ? ['--schema', schemas.join(',')] : [])];
|
|
200
|
-
const command = `${SUPABASE_CLI} ${args.join(' ')}`;
|
|
201
|
-
let result;
|
|
202
|
-
try {
|
|
203
|
-
result = run(SUPABASE_CLI, args, { cwd: root });
|
|
204
|
-
} catch (error) {
|
|
205
|
-
throw new DbTypesError(`\`${command}\` could not run: ${firstLine(error?.message ?? error) ?? 'unknown failure'}; the local Supabase stack must be up (supabase start)`, { file: dbTypesPath });
|
|
206
|
-
}
|
|
207
|
-
const stdout = String(result?.stdout ?? '');
|
|
208
|
-
const stderr = String(result?.stderr ?? '');
|
|
209
|
-
if (result?.error || result?.status !== 0 || !stdout.trim()) {
|
|
210
|
-
const cause = firstLine(stderr) ?? firstLine(result?.error?.message ?? result?.error) ?? 'no output';
|
|
211
|
-
throw new DbTypesError(`\`${command}\` failed${result?.status !== undefined && result?.status !== null ? ` (exit ${result.status})` : ''}: ${cause}; the local Supabase stack must be up (supabase start)`, { file: dbTypesPath, stderr });
|
|
212
|
-
}
|
|
213
|
-
// The CLI pads blank lines with spaces; the committed file carries none (git diff --check refuses trailing whitespace).
|
|
214
|
-
return `${TYPES_STAMP}${migrationsDigest(root)}\n${stdout.replace(/[ \t]+$/gm, '')}`;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
export function emitDbTypes({ root, run = spawn, platform = process.platform } = {}) {
|
|
218
|
-
return emitDbTypesWithRunner({ root, run: withNpxFallback(run, platform) });
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
* The types of an app that has no stack yet (a fresh scaffold): start the app's own local stack (its project id and ports from
|
|
223
|
-
* supabase/config.toml; every migration of supabase/migrations is applied on start), emit the types, and stop the stack again when
|
|
224
|
-
* this call started it. The slow, Docker-reaching path `starci app scaffold --edition lite` takes; `emitDbTypes` alone is the fast one for a stack that is up.
|
|
225
|
-
*/
|
|
226
|
-
export function generateDbTypes({ root, run = spawn, platform = process.platform } = {}) {
|
|
227
|
-
const execute = withNpxFallback(run, platform);
|
|
228
|
-
const started = execute(SUPABASE_CLI, ['start', '-x', 'studio,mailpit,logflare,vector,edge-runtime,imgproxy,supavisor'], { cwd: root });
|
|
229
|
-
const already = /already (?:running|started)/i.test(`${started?.stderr ?? ''}${started?.stdout ?? ''}`);
|
|
230
|
-
if (started?.error || (started?.status !== 0 && !already)) {
|
|
231
|
-
throw new DbTypesError(`\`${SUPABASE_CLI} start\` failed${started?.status != null ? ` (exit ${started.status})` : ''}: ${startDetail(started)} (the last ${START_TAIL_LINES} output lines at most; with no cause among them, Docker must be running)`, { file: dbTypesPath });
|
|
232
|
-
}
|
|
233
|
-
try {
|
|
234
|
-
return emitDbTypesWithRunner({ root, run: execute });
|
|
235
|
-
} finally {
|
|
236
|
-
if (!already) execute(SUPABASE_CLI, ['stop', '--no-backup'], { cwd: root });
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* Writes `emitDbTypes` to `<root>/<dbTypesPath>`, creating `supabase/types/` when needed. Answers `{ path, changed }`:
|
|
242
|
-
* `changed` is false when the committed text already equals the generated one, so a re-run is a no-op on disk.
|
|
243
|
-
*/
|
|
244
|
-
export function writeDbTypes({ root, run } = {}) {
|
|
245
|
-
const text = emitDbTypes({ root, run });
|
|
246
|
-
const target = path.join(root, dbTypesPath);
|
|
247
|
-
const committed = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null;
|
|
248
|
-
const changed = committed !== text;
|
|
249
|
-
if (changed) {
|
|
250
|
-
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
251
|
-
fs.writeFileSync(target, text);
|
|
252
|
-
}
|
|
253
|
-
return { path: dbTypesPath, changed };
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/**
|
|
257
|
-
* Whether the slot `app.supabase.types` is enabled for the app `resolver` views (a connection of the app declares
|
|
258
|
-
* provider: supabase). Read through the slot view, never through a path check: a side-scope resolver holds no app slots
|
|
259
|
-
* and answers false, exactly like an app without a supabase connection.
|
|
260
|
-
*/
|
|
261
|
-
export function appHasDbTypes(resolver) {
|
|
262
|
-
const slot = typeof resolver?.slot === 'function' ? resolver.slot(DB_TYPES_SLOT) : null;
|
|
263
|
-
return slot != null && typeof resolver?.slotEnabled === 'function' && resolver.slotEnabled(slot) === true;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/**
|
|
267
|
-
* The `emitTypes` callback checkDatabase (L09 DB_TYPES_DRIFT) expects: `({repoRoot, declaration}) -> text`, `repoRoot`
|
|
268
|
-
* being the app root under check; the bound `root` is the fallback for a caller that already knows it. The declared
|
|
269
|
-
* `supabase` block plays no part: the schemas come from supabase/config.toml on disk.
|
|
270
|
-
*/
|
|
271
|
-
export const dbTypesEmitter = ({ root = null, run } = {}) => async ({ repoRoot } = {}) => emitDbTypes({ root: repoRoot ?? root, run });
|
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
// database-dump.mjs - schema dump for an app's local or linked Supabase database.
|
|
2
|
-
import { supabaseCall } from './lib.mjs';
|
|
3
|
-
|
|
4
|
-
/** The spawn result of supabase db dump; arguments and runner options remain with the caller. */
|
|
5
|
-
export function databaseDump(...args) { return supabaseCall(['db', 'dump'], ...args); }
|
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
// database-push.mjs - pending SQL migration push for an app's selected Supabase database.
|
|
2
|
-
import { supabaseCall } from './lib.mjs';
|
|
3
|
-
|
|
4
|
-
/** The spawn result of supabase db push; target, preview and runner options remain with the owning caller. */
|
|
5
|
-
export function databasePush(...args) { return supabaseCall(['db', 'push'], ...args); }
|
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
// database-query.mjs - database query for an app's local or linked Supabase database.
|
|
2
|
-
import { supabaseCall } from './lib.mjs';
|
|
3
|
-
|
|
4
|
-
/** The spawn result of supabase db query; arguments and runner options remain with the caller. */
|
|
5
|
-
export function databaseQuery(...args) { return supabaseCall(['db', 'query'], ...args); }
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
// lib.mjs - resolve and spawn the app-local Supabase CLI for this API system, with no shell.
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import { spawnSync } from 'node:child_process';
|
|
5
|
-
|
|
6
|
-
const candidateNames = (platform) => platform === 'win32'
|
|
7
|
-
? ['supabase.exe', 'supabase', 'supabase.cmd']
|
|
8
|
-
: ['supabase'];
|
|
9
|
-
|
|
10
|
-
/** Resolve the app's installed npm launcher before executable discovery; npm command shims cannot run without a shell. */
|
|
11
|
-
function resolveSupabase(appRoot, { env = process.env, platform = process.platform, exists = fs.existsSync } = {}) {
|
|
12
|
-
const launcher = path.join(appRoot, 'node_modules', 'supabase', 'dist', 'supabase.js');
|
|
13
|
-
if (exists(launcher)) return { file: process.execPath, args: [launcher] };
|
|
14
|
-
const names = candidateNames(platform);
|
|
15
|
-
const local = path.join(appRoot, 'node_modules', '.bin');
|
|
16
|
-
for (const name of names) {
|
|
17
|
-
const file = path.join(local, name);
|
|
18
|
-
if (exists(file)) return { file, args: [] };
|
|
19
|
-
}
|
|
20
|
-
const delimiter = platform === 'win32' ? ';' : ':';
|
|
21
|
-
for (const directory of String(env.PATH ?? '').split(delimiter).filter(Boolean)) {
|
|
22
|
-
for (const name of names) {
|
|
23
|
-
const file = path.join(directory, name);
|
|
24
|
-
if (exists(file)) return { file, args: [] };
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
return { file: 'supabase', args: [] };
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** Run one shell-free Supabase call at the app root; an unusable command shim returns an ENOEXEC spawn failure. */
|
|
31
|
-
export const supabaseSpawn = (appRoot, args, {
|
|
32
|
-
env = process.env,
|
|
33
|
-
supabase,
|
|
34
|
-
timeout = 60_000,
|
|
35
|
-
run = spawnSync,
|
|
36
|
-
platform = process.platform,
|
|
37
|
-
exists = fs.existsSync,
|
|
38
|
-
} = {}) => {
|
|
39
|
-
const command = supabase === undefined || supabase === null ? resolveSupabase(appRoot, { env, platform, exists }) : { file: supabase, args: [] };
|
|
40
|
-
if (/\.(?:cmd|bat)$/iu.test(command.file)) return {
|
|
41
|
-
status: null, stdout: '', stderr: '', signal: null,
|
|
42
|
-
error: Object.assign(new Error('The Supabase command shim requires a shell; use an executable or the app-local npm launcher.'), { code: 'ENOEXEC' }),
|
|
43
|
-
};
|
|
44
|
-
return run(command.file, [...command.args, ...args], {
|
|
45
|
-
cwd: appRoot,
|
|
46
|
-
encoding: 'utf8',
|
|
47
|
-
env,
|
|
48
|
-
timeout,
|
|
49
|
-
windowsHide: true,
|
|
50
|
-
});
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
/** Invoke one Supabase command prefix with caller arguments and runner options. */
|
|
54
|
-
export const supabaseCall = (prefix, appRoot, args = [], options = {}) =>
|
|
55
|
-
supabaseSpawn(appRoot, [...prefix, ...args], options);
|
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
// migration-repair.mjs - migration history repair for an app's local or linked Supabase database.
|
|
2
|
-
import { supabaseCall } from './lib.mjs';
|
|
3
|
-
|
|
4
|
-
/** The spawn result of supabase migration repair; arguments and runner options remain with the caller. */
|
|
5
|
-
export function migrationRepair(...args) { return supabaseCall(['migration', 'repair'], ...args); }
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
|
|
4
|
-
const CACHE = new Map();
|
|
5
|
-
|
|
6
|
-
/** A static TypeScript property name, as emitted by `supabase gen types typescript`. */
|
|
7
|
-
const propertyName = (typescript, name) => (typescript.isIdentifier(name) || typescript.isStringLiteralLike(name) ? name.text : null);
|
|
8
|
-
|
|
9
|
-
/** The type-literal members below property `name`, or null when the generated shape is not statically readable. */
|
|
10
|
-
const propertyMembers = (typescript, members, name) => {
|
|
11
|
-
const property = members.find(member => typescript.isPropertySignature(member) && propertyName(typescript, member.name) === name);
|
|
12
|
-
return property?.type && typescript.isTypeLiteralNode(property.type) ? property.type.members : null;
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
function addTableNames(typescript, members, tables) {
|
|
16
|
-
if (!members) return;
|
|
17
|
-
for (const member of members) {
|
|
18
|
-
if (!typescript.isPropertySignature(member)) continue;
|
|
19
|
-
const name = propertyName(typescript, member.name);
|
|
20
|
-
if (name) tables.add(name.toLowerCase());
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function readTableNames(typescript, file, tables) {
|
|
25
|
-
const source = typescript.createSourceFile(file, fs.readFileSync(file, 'utf8'), typescript.ScriptTarget.Latest, true, typescript.ScriptKind.TS);
|
|
26
|
-
if (source.parseDiagnostics.length !== 0) return;
|
|
27
|
-
const database = source.statements.find(statement => typescript.isTypeAliasDeclaration(statement) && statement.name.text === 'Database');
|
|
28
|
-
const databaseMembers = database && typescript.isTypeLiteralNode(database.type) ? database.type.members : null;
|
|
29
|
-
const publicMembers = databaseMembers ? propertyMembers(typescript, databaseMembers, 'public') : null;
|
|
30
|
-
const tableMembers = publicMembers ? propertyMembers(typescript, publicMembers, 'Tables') : null;
|
|
31
|
-
addTableNames(typescript, tableMembers, tables);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* The lower-case keys of `Database['public']['Tables']`, parsed with the architecture context's target compiler.
|
|
36
|
-
* An absent, malformed or unreadable contract proves no table. The result is cached once per app root.
|
|
37
|
-
*/
|
|
38
|
-
export function supabaseTablesOf(appRoot, typescript) {
|
|
39
|
-
const root = path.resolve(appRoot);
|
|
40
|
-
if (CACHE.has(root)) return CACHE.get(root);
|
|
41
|
-
const tables = new Set();
|
|
42
|
-
const file = path.join(root, 'supabase', 'types', 'database.types.ts');
|
|
43
|
-
try {
|
|
44
|
-
readTableNames(typescript, file, tables);
|
|
45
|
-
} catch {
|
|
46
|
-
// Missing and unreadable generated contracts are fail-closed: no table is proven.
|
|
47
|
-
}
|
|
48
|
-
CACHE.set(root, tables);
|
|
49
|
-
return tables;
|
|
50
|
-
}
|
|
@@ -1,226 +0,0 @@
|
|
|
1
|
-
import { machineKit } from './machine-ast.mjs';
|
|
2
|
-
import { callName, chainParts, contextTypesClient, isDatabaseType, isSupabaseCall, jwtPolicyProblems, moduleNameOf, reportAt, supabaseClientType, supabaseDataAccess, SUPABASE_MODULES } from './supabase-ast.mjs';
|
|
3
|
-
import { checkBackendJwt } from './supabase-be.mjs';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* L10-L16, the slot-driven Supabase machine. On the back end Supabase provides Auth/Storage with one owner,
|
|
7
|
-
* `be.integrations.supabase`. On the front end Supabase is auth only (owner ruling 2026-10-13): `fe.modules.auth` is the one
|
|
8
|
-
* importer of `@supabase/*` and calls only the auth API; data, storage, rpc and realtime go through the back end (modules/api).
|
|
9
|
-
* The checks below recognise provider calls and types by their imported package declarations, not by local variable or type names.
|
|
10
|
-
*/
|
|
11
|
-
export const FRONTEND_SUPABASE_RULE_IDS = Object.freeze(['FE_SUPABASE_CLIENT_OWNER', 'FE_SERVICE_ROLE_FORBIDDEN']);
|
|
12
|
-
export const BACKEND_SUPABASE_RULE_IDS = Object.freeze(['BE_SUPABASE_CLIENT_OWNER', 'BE_SUPABASE_JWT_VERIFIED', 'BE_JWT_ALG_PINNED']);
|
|
13
|
-
|
|
14
|
-
const FE_CLIENT_OWNER = 'FE_SUPABASE_CLIENT_OWNER';
|
|
15
|
-
const BE_CLIENT_OWNER = 'BE_SUPABASE_CLIENT_OWNER';
|
|
16
|
-
const FE_SERVICE_ROLE = 'FE_SERVICE_ROLE_FORBIDDEN';
|
|
17
|
-
const BE_SUPABASE_SLOT = 'be.integrations.supabase';
|
|
18
|
-
const FE_AUTH_SLOT = 'fe.modules.auth';
|
|
19
|
-
/** The front-end provider slot: enabled exactly when a connection declares provider supabase. */
|
|
20
|
-
const FE_SUPABASE_PROVIDER_SLOT = 'fe.route.callback';
|
|
21
|
-
const FE_CONFIG_SLOT = 'fe.modules.config';
|
|
22
|
-
const BE_DATA_MEMBERS = new Set(['from', 'rpc', 'schema', 'functions', 'channel', 'realtime', 'getChannels', 'removeChannel', 'removeAllChannels']);
|
|
23
|
-
/** The members of a Supabase client that read or write data, files or channels: never called from the front end. */
|
|
24
|
-
const FE_DATA_MEMBERS = new Set(['from', 'rpc', 'schema', 'storage', 'functions', 'realtime', 'channel', 'getChannels', 'removeChannel', 'removeAllChannels']);
|
|
25
|
-
const supabaseSlotEnabled = (input, slotId) => {
|
|
26
|
-
const slot = input.graph.resolver.slot(slotId);
|
|
27
|
-
return Boolean(slot && input.graph.resolver.slotEnabled(slot));
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
const CLIENT_FACTORY = /^create(?:Client|ServerClient|BrowserClient)$/u;
|
|
31
|
-
|
|
32
|
-
function inspectSupabaseImport(scope, node) {
|
|
33
|
-
const { kit, file, slots, ruleId, violations, counters } = scope;
|
|
34
|
-
const moduleName = moduleNameOf(kit.ts, node);
|
|
35
|
-
if (!moduleName || !SUPABASE_MODULES.has(moduleName)) return;
|
|
36
|
-
counters.imports += 1;
|
|
37
|
-
if (!slots.has(file.slot)) reportAt(violations, kit, ruleId, file, node, `${moduleName} is imported by ${file.rel}, whose slot is ${file.slot ?? 'unowned'}; Supabase imports belong only to ${[...slots].join(' or ')}.`, { module: moduleName, slot: file.slot ?? null });
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function inspectClientTypeReference(scope, node) {
|
|
41
|
-
const { kit, checker, file, ruleId, violations, counters } = scope;
|
|
42
|
-
if (!kit.ts.isTypeReferenceNode(node) || !supabaseClientType(kit, checker, node)) return;
|
|
43
|
-
counters.clients += 1;
|
|
44
|
-
if (!isDatabaseType(kit.ts, node.typeArguments?.[0])) reportAt(violations, kit, ruleId, file, node, 'SupabaseClient must carry the committed Database type as SupabaseClient<Database>.');
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function inspectClientFactoryCall(scope, node) {
|
|
48
|
-
const { kit, checker, file, ruleId, violations, counters } = scope;
|
|
49
|
-
const binding = kit.importBinding(checker, node.expression);
|
|
50
|
-
if (!binding || !SUPABASE_MODULES.has(binding.module) || !CLIENT_FACTORY.test(binding.name)) return;
|
|
51
|
-
counters.clients += 1;
|
|
52
|
-
if (!isDatabaseType(kit.ts, node.typeArguments?.[0]) && !contextTypesClient(kit, checker, node)) {
|
|
53
|
-
reportAt(violations, kit, ruleId, file, node, `${binding.name} creates an untyped Supabase client; create it as ${binding.name}<Database>(...) (or give it the contextual type SupabaseClient<Database>).`);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function inspectProviderCall(scope, node) {
|
|
58
|
-
const { kit, checker, file, slots, ruleId, violations, counters, reportedData } = scope;
|
|
59
|
-
const { ts } = kit;
|
|
60
|
-
const data = supabaseDataAccess(kit, checker, node, BE_DATA_MEMBERS, { storageAllowed: true });
|
|
61
|
-
if (data) {
|
|
62
|
-
if (!reportedData.has(data.node.pos)) {
|
|
63
|
-
reportedData.add(data.node.pos);
|
|
64
|
-
reportAt(violations, kit, ruleId, file, data.node, `A Supabase .${data.name} data call is made from ${file.slot ?? 'an unowned path'}; the back-end SDK is Auth/Storage only, including inside ${BE_SUPABASE_SLOT}. Data uses EntityManager as app_be.`, { member: data.name, slot: file.slot ?? null });
|
|
65
|
-
}
|
|
66
|
-
if (ts.isCallExpression(node)) counters.calls += 1;
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
if (!isSupabaseCall(kit, checker, node)) return;
|
|
70
|
-
const properties = chainParts(ts, node);
|
|
71
|
-
const method = callName(ts, node);
|
|
72
|
-
if (!properties.includes('auth') && !properties.includes('storage')) return;
|
|
73
|
-
counters.calls += 1;
|
|
74
|
-
if (!slots.has(file.slot)) reportAt(violations, kit, ruleId, file, node, `A Supabase ${method ?? 'client'} call is made from ${file.slot ?? 'an unowned path'}; database, auth and storage calls belong only to ${[...slots].join(' or ')}.`, { method, slot: file.slot ?? null });
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function inspectOwnershipNode(scope, node) {
|
|
78
|
-
inspectSupabaseImport(scope, node);
|
|
79
|
-
inspectClientTypeReference(scope, node);
|
|
80
|
-
const { ts } = scope.kit;
|
|
81
|
-
if (!ts.isCallExpression(node) && !ts.isPropertyAccessExpression(node) && !ts.isElementAccessExpression(node)) return;
|
|
82
|
-
if (ts.isCallExpression(node)) inspectClientFactoryCall(scope, node);
|
|
83
|
-
inspectProviderCall(scope, node);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function checkClientOwnership(input, { slots, ruleId }) {
|
|
87
|
-
const kit = machineKit(input);
|
|
88
|
-
const violations = [];
|
|
89
|
-
const counters = { imports: 0, clients: 0, calls: 0 };
|
|
90
|
-
for (const file of input.graph.files.values()) {
|
|
91
|
-
const checker = kit.checkerOf(file.sourceFile);
|
|
92
|
-
const scope = { kit, checker, file, slots, ruleId, violations, counters, reportedData: new Set() };
|
|
93
|
-
kit.walk(file.sourceFile, node => inspectOwnershipNode(scope, node));
|
|
94
|
-
}
|
|
95
|
-
return { violations, coverage: { status: 'checked', ...counters, ownerSlots: [...slots] } };
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const words = text => String(text).replace(/([a-z0-9])([A-Z])/gu, '$1_$2').toUpperCase();
|
|
99
|
-
const serviceRoleText = text => /(?:^|[^A-Z0-9])SERVICE[_ -]?ROLE(?:[^A-Z0-9]|$)/iu.test(words(text));
|
|
100
|
-
const credentialEnv = name => /(?:^|_)(?:SECRET|PRIVATE|TOKEN|PASSWORD)$/u.test(name) || /(?:^|_)SERVICE_ROLE(?:_|$)/u.test(name);
|
|
101
|
-
const CORE_PUBLIC_ENV = new Set(['NEXT_PUBLIC_SUPABASE_URL', 'NEXT_PUBLIC_SUPABASE_ANON_KEY', 'NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY', 'NEXT_PUBLIC_SITE_URL']);
|
|
102
|
-
|
|
103
|
-
const envRead = (ts, node) => {
|
|
104
|
-
if (!ts.isPropertyAccessExpression(node) && !ts.isElementAccessExpression(node)) return null;
|
|
105
|
-
const object = node.expression;
|
|
106
|
-
const processEnv = ts.isPropertyAccessExpression(object) && ts.isIdentifier(object.expression) && object.expression.text === 'process' && object.name.text === 'env';
|
|
107
|
-
const importMetaEnv = ts.isPropertyAccessExpression(object) && object.name.text === 'env' && object.expression.kind === ts.SyntaxKind.MetaProperty;
|
|
108
|
-
if (!processEnv && !importMetaEnv) return null;
|
|
109
|
-
if (ts.isPropertyAccessExpression(node)) return node.name.text;
|
|
110
|
-
return ts.isStringLiteralLike(node.argumentExpression) ? node.argumentExpression.text : null;
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
/** Reports a SERVICE_ROLE identifier or string literal: the browser and Next process never hold the service role. */
|
|
114
|
-
function inspectServiceRoleText(ts, node, report) {
|
|
115
|
-
if (ts.isIdentifier(node) && serviceRoleText(node.text)) report(node, 'A SERVICE_ROLE identifier appears in the front end; the browser and Next process never hold the Supabase service role.', node.text);
|
|
116
|
-
if (ts.isStringLiteralLike(node) && serviceRoleText(node.text)) report(node, 'A service_role string appears in the front end; the service role belongs behind the back-end guard chain.', node.text);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/** Counts an environment read and reports a credential-shaped or product-specific public variable. */
|
|
120
|
-
function inspectEnvironmentRead(state, file, node, name) {
|
|
121
|
-
state.reads += 1;
|
|
122
|
-
if (credentialEnv(name)) state.report(node, `${name} is a credential-shaped environment read in the front end; no *_SECRET, *_PRIVATE, *_TOKEN, *_PASSWORD or service-role value enters fe/.`, name);
|
|
123
|
-
else if (name.startsWith('NEXT_PUBLIC_') && !CORE_PUBLIC_ENV.has(name) && file.slot !== FE_CONFIG_SLOT) {
|
|
124
|
-
state.report(node, `${name} is not a core Supabase/site public variable and is read outside ${FE_CONFIG_SLOT}; product public configuration is declared by the one config owner.`, name);
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function checkFrontendSecretNames(input) {
|
|
129
|
-
const kit = machineKit(input);
|
|
130
|
-
const { ts } = kit;
|
|
131
|
-
const violations = [];
|
|
132
|
-
const state = { reads: 0, report: null };
|
|
133
|
-
for (const file of input.graph.files.values()) {
|
|
134
|
-
const seen = new Set();
|
|
135
|
-
state.report = (node, message, name) => {
|
|
136
|
-
const key = `${node.pos}:${name ?? ''}`;
|
|
137
|
-
if (seen.has(key)) return;
|
|
138
|
-
seen.add(key);
|
|
139
|
-
reportAt(violations, kit, FE_SERVICE_ROLE, file, node, message, name ? { name } : {});
|
|
140
|
-
};
|
|
141
|
-
kit.walk(file.sourceFile, node => {
|
|
142
|
-
inspectServiceRoleText(ts, node, state.report);
|
|
143
|
-
const name = envRead(ts, node);
|
|
144
|
-
if (name) inspectEnvironmentRead(state, file, node, name);
|
|
145
|
-
});
|
|
146
|
-
}
|
|
147
|
-
return { violations, coverage: { status: 'checked', envReads: state.reads } };
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function inspectFrontendNode(scope, node) {
|
|
151
|
-
const { kit, checker, file, violations, counters, reportedData } = scope;
|
|
152
|
-
const { ts } = kit;
|
|
153
|
-
const moduleName = moduleNameOf(ts, node);
|
|
154
|
-
if (moduleName?.startsWith('@supabase/')) {
|
|
155
|
-
counters.imports += 1;
|
|
156
|
-
if (file.slot !== FE_AUTH_SLOT) reportAt(violations, kit, FE_CLIENT_OWNER, file, node, `${moduleName} is imported by ${file.rel}, whose slot is ${file.slot ?? 'unowned'}; on the front end Supabase is auth only and ${FE_AUTH_SLOT} (modules/auth) is its one importer.`, { module: moduleName, slot: file.slot ?? null });
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
if (!ts.isCallExpression(node) && !ts.isPropertyAccessExpression(node) && !ts.isElementAccessExpression(node)) return;
|
|
160
|
-
const data = supabaseDataAccess(kit, checker, node, FE_DATA_MEMBERS);
|
|
161
|
-
if (data) {
|
|
162
|
-
if (!reportedData.has(data.node.pos)) {
|
|
163
|
-
reportedData.add(data.node.pos);
|
|
164
|
-
reportAt(violations, kit, FE_CLIENT_OWNER, file, data.node, `A Supabase .${data.name} call is made from the front end; Supabase on the front end is auth only - read and write data, files and realtime through the back end (a modules/api GraphQL operation, an SWR hook, hooks/socketio).`, { member: data.name, slot: file.slot ?? null });
|
|
165
|
-
}
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
|
-
if (!isSupabaseCall(kit, checker, node)) return;
|
|
169
|
-
counters.calls += 1;
|
|
170
|
-
const parts = chainParts(ts, node);
|
|
171
|
-
if (file.slot !== FE_AUTH_SLOT && parts.includes('auth')) reportAt(violations, kit, FE_CLIENT_OWNER, file, node, `A Supabase auth call is made from ${file.slot ?? 'an unowned path'}; the auth client is called only inside ${FE_AUTH_SLOT}, which exports the session and the access token.`, { slot: file.slot ?? null });
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function checkFrontendClientOwner(input) {
|
|
175
|
-
const kit = machineKit(input);
|
|
176
|
-
const violations = [];
|
|
177
|
-
const counters = { imports: 0, calls: 0 };
|
|
178
|
-
for (const file of input.graph.files.values()) {
|
|
179
|
-
const scope = { kit, checker: kit.checkerOf(file.sourceFile), file, violations, counters, reportedData: new Set() };
|
|
180
|
-
kit.walk(file.sourceFile, node => inspectFrontendNode(scope, node));
|
|
181
|
-
}
|
|
182
|
-
return { violations, coverage: { status: 'checked', ...counters, ownerSlots: [FE_AUTH_SLOT] } };
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
export function checkFrontendSupabase(input) {
|
|
186
|
-
const owner = checkFrontendClientOwner(input);
|
|
187
|
-
if (!supabaseSlotEnabled(input, FE_SUPABASE_PROVIDER_SLOT)) return {
|
|
188
|
-
violations: owner.violations,
|
|
189
|
-
coverage: { status: owner.coverage.imports || owner.coverage.calls || owner.violations.length ? 'checked' : 'not-applicable', owner: owner.coverage, reason: 'no connection declares provider supabase; SDK ownership is still checked' },
|
|
190
|
-
};
|
|
191
|
-
const secrets = checkFrontendSecretNames(input);
|
|
192
|
-
return {
|
|
193
|
-
violations: [...owner.violations, ...secrets.violations],
|
|
194
|
-
coverage: { status: 'checked', owner: owner.coverage, secrets: secrets.coverage },
|
|
195
|
-
};
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
export function checkBackendSupabase(input) {
|
|
199
|
-
const owner = checkClientOwnership(input, { slots: new Set([BE_SUPABASE_SLOT]), ruleId: BE_CLIENT_OWNER });
|
|
200
|
-
if (!supabaseSlotEnabled(input, BE_SUPABASE_SLOT)) return {
|
|
201
|
-
violations: owner.violations,
|
|
202
|
-
coverage: { status: owner.coverage.imports || owner.coverage.calls || owner.violations.length ? 'checked' : 'not-applicable', owner: owner.coverage, reason: 'the Supabase integration slot is disabled; SDK ownership is still checked' },
|
|
203
|
-
};
|
|
204
|
-
const jwt = checkBackendJwt(input);
|
|
205
|
-
const policy = checkJwtPolicy(input);
|
|
206
|
-
return {
|
|
207
|
-
violations: [...owner.violations, ...jwt.violations, ...policy.violations],
|
|
208
|
-
coverage: { status: 'checked', owner: owner.coverage, jwt: jwt.coverage, jwtPolicy: policy.coverage },
|
|
209
|
-
};
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
export function checkJwtPolicy(input) {
|
|
213
|
-
const kit = machineKit(input);
|
|
214
|
-
const violations = [];
|
|
215
|
-
let verifyCalls = 0;
|
|
216
|
-
for (const file of input.graph.files.values()) {
|
|
217
|
-
const checker = kit.checkerOf(file.sourceFile);
|
|
218
|
-
kit.walk(file.sourceFile, node => {
|
|
219
|
-
if (!kit.ts.isCallExpression(node) || !kit.isImportOf(checker, node.expression, 'jwtVerify', 'jose')) return;
|
|
220
|
-
verifyCalls += 1;
|
|
221
|
-
const problems = jwtPolicyProblems(kit.ts, checker, node);
|
|
222
|
-
if (problems.length) reportAt(violations, kit, 'BE_JWT_ALG_PINNED', file, node, problems.join(' '));
|
|
223
|
-
});
|
|
224
|
-
}
|
|
225
|
-
return { violations, coverage: { status: 'checked', verifyCalls } };
|
|
226
|
-
}
|