@mccaskie-labs/workbench-supabase 0.3.0 → 0.4.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 +11 -0
- package/README.md +15 -0
- package/dist/cli.js +30 -3
- package/dist/limits-check.d.ts +8 -0
- package/dist/limits-check.js +138 -0
- package/dist/limits-sql.d.ts +1 -0
- package/dist/limits-sql.js +28 -0
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# @mccaskie-labs/workbench-supabase
|
|
2
2
|
|
|
3
|
+
## 0.4.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies
|
|
8
|
+
- @mccaskie-labs/workbench-forms@0.2.0
|
|
9
|
+
|
|
10
|
+
## 0.4.0
|
|
11
|
+
|
|
12
|
+
- `check:limits`: compares the app's limits register (`workbench-forms`) with snapshots of the real columns on staging (required, and taken after every migration in the repo) and production (when there is one). `limits-sql` prints the read-only query for `sb_query`; `limits-save --env staging|production` saves its result, stamped with the repo's migrations, as `supabase/<env>-columns.json`.
|
|
13
|
+
|
|
3
14
|
## 0.3.0
|
|
4
15
|
|
|
5
16
|
- `createSessions({ url, publishableKey, secret })`: app sign-in by typed emailed code with the session held by the server (authentication standard §4, §6, §10). `sendCode`, `verifyCode`, `read`, `signOut`, and a pending-sign-in cookie that grants nothing. The session is one AES-GCM sealed `__Host-session` cookie (Secure, HttpOnly, SameSite=Lax) with four dials: idle on a private device (14 days), on a shared one (30 minutes), an absolute cap (30 days, never moved by a refresh) and a freshness window (15 minutes). The access token is refreshed by the server and never reaches page script. Sign-out clears the cookie first, with every attribute, then ends this session only at Supabase.
|
package/README.md
CHANGED
|
@@ -80,4 +80,19 @@ A use that is kept off production by a switch is allowed by an entry in `supabas
|
|
|
80
80
|
|
|
81
81
|
It is a tripwire, not a proof: it sees names in string literals only (`.from('x')`, `.rpc('x')`, `.select('a, x')`), not names built at run time.
|
|
82
82
|
|
|
83
|
+
### check:limits
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
npx workbench-supabase check:limits # src/limits.ts (or --register <path>) against the database's real column sizes
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The register is the app's `LIMITS` from `@mccaskie-labs/workbench-forms/limits`. It is compared with `supabase/staging-columns.json` and, when there is one, `supabase/production-columns.json`. Refresh a snapshot right after migrations apply on that database:
|
|
90
|
+
|
|
91
|
+
```sh
|
|
92
|
+
npx workbench-supabase limits-sql # the read-only query: run it with sb_query env=staging
|
|
93
|
+
npx workbench-supabase limits-save --env staging --from result.json # or pipe the result in
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`limits-save` stamps the snapshot with the repo's migrations; `check:limits` fails when the staging snapshot is older than any migration in the repo, so it can't silently go stale. Text limits need `varchar(max)` or `text` with `check (char_length(col) <= max)`, money `numeric(p, scale)`, whole numbers an integer type holding the range (and equal `check` bounds when there are any), dates `date`.
|
|
97
|
+
|
|
83
98
|
Runs on Node 24 and the Cloudflare Workers runtime (the CLI on Node only). `zod` 4 is a peer dependency, through `workbench-config`.
|
package/dist/cli.js
CHANGED
|
@@ -4,10 +4,14 @@
|
|
|
4
4
|
// workbench-supabase check:migrations [dir]
|
|
5
5
|
// workbench-supabase check:prod-schema [dir] [--code <path>]...
|
|
6
6
|
// workbench-supabase prod-schema-sql
|
|
7
|
+
// workbench-supabase check:limits [dir] [--register <path>]
|
|
8
|
+
// workbench-supabase limits-sql
|
|
9
|
+
// workbench-supabase limits-save [dir] --env staging|production [--from <file>] (else reads stdin)
|
|
7
10
|
//
|
|
8
11
|
// `dir` is the project root (default: the current folder). Offline; reads files only.
|
|
9
12
|
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
10
13
|
import { join, relative, sep } from "node:path";
|
|
14
|
+
import { LIMITS_SQL } from "./limits-sql.js";
|
|
11
15
|
import { migrationProblems } from "./migrations.js";
|
|
12
16
|
import { findUses, missingOnProduction, parseAllow, parseProductionSchema, PRODUCTION_SCHEMA_SQL, unusedAllows } from "./prod-schema.js";
|
|
13
17
|
const MIGRATIONS = "supabase/migrations";
|
|
@@ -111,13 +115,22 @@ function checkProdSchema(dir, code) {
|
|
|
111
115
|
console.log("OK. Limits: direct uses in string literals only; names built at run time aren't seen.");
|
|
112
116
|
return 0;
|
|
113
117
|
}
|
|
114
|
-
function main(argv) {
|
|
118
|
+
async function main(argv) {
|
|
115
119
|
const [command, ...rest] = argv;
|
|
116
120
|
const code = [];
|
|
117
121
|
const positional = [];
|
|
122
|
+
let registerPath = "src/limits.ts";
|
|
123
|
+
let env = "";
|
|
124
|
+
let from = "";
|
|
118
125
|
for (let i = 0; i < rest.length; i++) {
|
|
119
126
|
if (rest[i] === "--code" && rest[i + 1])
|
|
120
127
|
code.push(rest[++i]);
|
|
128
|
+
else if (rest[i] === "--register" && rest[i + 1])
|
|
129
|
+
registerPath = rest[++i];
|
|
130
|
+
else if (rest[i] === "--env" && rest[i + 1])
|
|
131
|
+
env = rest[++i];
|
|
132
|
+
else if (rest[i] === "--from" && rest[i + 1])
|
|
133
|
+
from = rest[++i];
|
|
121
134
|
else
|
|
122
135
|
positional.push(rest[i]);
|
|
123
136
|
}
|
|
@@ -130,7 +143,21 @@ function main(argv) {
|
|
|
130
143
|
console.log(PRODUCTION_SCHEMA_SQL);
|
|
131
144
|
return 0;
|
|
132
145
|
}
|
|
133
|
-
|
|
146
|
+
if (command === "check:limits") {
|
|
147
|
+
// Loaded only here, so the other checks don't need workbench-forms.
|
|
148
|
+
const { checkLimits } = await import("./limits-check.js");
|
|
149
|
+
return checkLimits(dir, registerPath);
|
|
150
|
+
}
|
|
151
|
+
if (command === "limits-save") {
|
|
152
|
+
const { limitsSave } = await import("./limits-check.js");
|
|
153
|
+
return limitsSave(dir, env, readFileSync(from || 0, "utf8"));
|
|
154
|
+
}
|
|
155
|
+
if (command === "limits-sql") {
|
|
156
|
+
console.log(LIMITS_SQL);
|
|
157
|
+
return 0;
|
|
158
|
+
}
|
|
159
|
+
console.error("Usage: workbench-supabase check:migrations [dir] | check:prod-schema [dir] [--code <path>]... | prod-schema-sql | " +
|
|
160
|
+
"check:limits [dir] [--register <path>] | limits-sql | limits-save [dir] --env staging|production [--from <file>]");
|
|
134
161
|
return 2;
|
|
135
162
|
}
|
|
136
|
-
process.exitCode = main(process.argv.slice(2));
|
|
163
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* limits-save: writes supabase/<env>-columns.json from LIMITS_SQL's result,
|
|
3
|
+
* stamped with the environment and the repo's migrations. Run it right after
|
|
4
|
+
* the migrations apply on that environment, so the stamp is true.
|
|
5
|
+
*/
|
|
6
|
+
export declare function limitsSave(dir: string, env: string, input: string): number;
|
|
7
|
+
/** Runs check:limits and returns the exit code; messages go to the console. */
|
|
8
|
+
export declare function checkLimits(dir: string, registerPath: string): Promise<number>;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// check:limits (docs/spec.md §3; form-validation standard §4.1): the app's
|
|
2
|
+
// limits register (workbench-forms) against the column snapshots in
|
|
3
|
+
// supabase/staging-columns.json (required, and taken after every migration in
|
|
4
|
+
// the repo) and supabase/production-columns.json (when there is one).
|
|
5
|
+
// Offline: the snapshots come from LIMITS_SQL, run with sb_query, and are
|
|
6
|
+
// saved by limits-save, which stamps them with the repo's migrations.
|
|
7
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
8
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
9
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
10
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
return path;
|
|
14
|
+
};
|
|
15
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { join, resolve } from "node:path";
|
|
17
|
+
import { pathToFileURL } from "node:url";
|
|
18
|
+
import { defineLimits, limitsProblems, parseColumnSnapshot } from "@mccaskie-labs/workbench-forms/limits";
|
|
19
|
+
const MIGRATIONS = "supabase/migrations";
|
|
20
|
+
const COLUMNS = (env) => `supabase/${env}-columns.json`;
|
|
21
|
+
const list = (dir) => (existsSync(dir) ? readdirSync(dir) : []);
|
|
22
|
+
function readJson(path, what) {
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
throw new Error(`${what} isn't valid JSON: ${e.message}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** The repo's migration versions, oldest first. */
|
|
31
|
+
function migrationVersions(dir) {
|
|
32
|
+
return list(join(dir, MIGRATIONS))
|
|
33
|
+
.map((f) => /^(\d{14})_.+\.sql$/.exec(f)?.[1])
|
|
34
|
+
.filter((v) => v !== undefined)
|
|
35
|
+
.sort();
|
|
36
|
+
}
|
|
37
|
+
/** The snapshot inside whatever sb_query returned: the object itself, { snapshot }, { rows: [...] } or a list of rows. */
|
|
38
|
+
function findSnapshot(value, depth = 0) {
|
|
39
|
+
if (!value || typeof value !== "object" || depth > 4)
|
|
40
|
+
return null;
|
|
41
|
+
if (Array.isArray(value))
|
|
42
|
+
return value.length ? findSnapshot(value[0], depth + 1) : null;
|
|
43
|
+
const v = value;
|
|
44
|
+
if (v.format === 1 && v.columns && typeof v.columns === "object")
|
|
45
|
+
return v;
|
|
46
|
+
for (const key of ["snapshot", "rows", "result", "data"])
|
|
47
|
+
if (key in v)
|
|
48
|
+
return findSnapshot(v[key], depth + 1);
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* limits-save: writes supabase/<env>-columns.json from LIMITS_SQL's result,
|
|
53
|
+
* stamped with the environment and the repo's migrations. Run it right after
|
|
54
|
+
* the migrations apply on that environment, so the stamp is true.
|
|
55
|
+
*/
|
|
56
|
+
export function limitsSave(dir, env, input) {
|
|
57
|
+
if (env !== "staging" && env !== "production") {
|
|
58
|
+
console.error("limits-save: --env is staging or production");
|
|
59
|
+
return 2;
|
|
60
|
+
}
|
|
61
|
+
let parsed;
|
|
62
|
+
try {
|
|
63
|
+
parsed = JSON.parse(input);
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
console.error(`limits-save: the input isn't JSON: ${e.message}`);
|
|
67
|
+
return 2;
|
|
68
|
+
}
|
|
69
|
+
const found = findSnapshot(parsed);
|
|
70
|
+
if (!found) {
|
|
71
|
+
console.error("limits-save: no snapshot in the input. Pass the result of `workbench-supabase limits-sql` run with sb_query.");
|
|
72
|
+
return 2;
|
|
73
|
+
}
|
|
74
|
+
const snapshot = { format: 1, env, migrations: migrationVersions(dir), columns: found.columns };
|
|
75
|
+
try {
|
|
76
|
+
parseColumnSnapshot(snapshot);
|
|
77
|
+
}
|
|
78
|
+
catch (e) {
|
|
79
|
+
console.error(`limits-save: ${e.message}`);
|
|
80
|
+
return 2;
|
|
81
|
+
}
|
|
82
|
+
writeFileSync(join(dir, COLUMNS(env)), `${JSON.stringify(snapshot, null, 2)}\n`);
|
|
83
|
+
console.log(`limits-save: wrote ${COLUMNS(env)} (${Object.keys(snapshot.columns).length} columns, ${snapshot.migrations.length} migrations).`);
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
/** Runs check:limits and returns the exit code; messages go to the console. */
|
|
87
|
+
export async function checkLimits(dir, registerPath) {
|
|
88
|
+
const path = resolve(dir, registerPath);
|
|
89
|
+
if (!existsSync(path)) {
|
|
90
|
+
console.error(`check:limits: no register at ${registerPath}. Export LIMITS = defineLimits({...}) from it (workbench-forms/limits), or pass --register <path>.`);
|
|
91
|
+
return 2;
|
|
92
|
+
}
|
|
93
|
+
let register;
|
|
94
|
+
try {
|
|
95
|
+
const mod = (await import(__rewriteRelativeImportExtension(pathToFileURL(path).href)));
|
|
96
|
+
register = defineLimits((mod.LIMITS ?? mod.default));
|
|
97
|
+
}
|
|
98
|
+
catch (e) {
|
|
99
|
+
console.error(`check:limits: ${registerPath}: ${e.message}`);
|
|
100
|
+
return 2;
|
|
101
|
+
}
|
|
102
|
+
const migrations = migrationVersions(dir);
|
|
103
|
+
const problems = [];
|
|
104
|
+
let read = 0;
|
|
105
|
+
for (const env of ["staging", "production"]) {
|
|
106
|
+
const file = join(dir, COLUMNS(env));
|
|
107
|
+
if (!existsSync(file)) {
|
|
108
|
+
if (env === "staging") {
|
|
109
|
+
console.error(`check:limits: no ${COLUMNS("staging")}. After the migrations apply on staging, run \`workbench-supabase limits-sql\` with ` +
|
|
110
|
+
"sb_query env=staging and pipe the result to `workbench-supabase limits-save --env staging`.");
|
|
111
|
+
return 2;
|
|
112
|
+
}
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
const snapshot = parseColumnSnapshot(readJson(file, COLUMNS(env)));
|
|
117
|
+
if (snapshot.env !== env)
|
|
118
|
+
throw new Error(`it says it is from ${String(snapshot.env)}`);
|
|
119
|
+
problems.push(...limitsProblems(register, snapshot, env === "production" ? [] : migrations));
|
|
120
|
+
read++;
|
|
121
|
+
}
|
|
122
|
+
catch (e) {
|
|
123
|
+
console.error(`check:limits: ${COLUMNS(env)}: ${e.message}`);
|
|
124
|
+
return 2;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const entries = Object.values(register);
|
|
128
|
+
const stored = entries.filter((l) => l.column !== null).length;
|
|
129
|
+
const proposed = entries.filter((l) => l.status === "proposed").length;
|
|
130
|
+
if (problems.length) {
|
|
131
|
+
for (const p of problems)
|
|
132
|
+
console.error(` ${p}`);
|
|
133
|
+
console.error(`check:limits failed: ${problems.length} problem(s). Change the register or the column (a migration) so they agree.`);
|
|
134
|
+
return 1;
|
|
135
|
+
}
|
|
136
|
+
console.log(`check:limits passed: ${stored} stored limit(s) match ${read} database snapshot(s); ${proposed} still proposed.`);
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const LIMITS_SQL = "select json_build_object(\n 'format', 1,\n 'columns', coalesce((\n select json_object_agg(c.relname || '.' || a.attname, json_build_object(\n 'type', format_type(a.atttypid, null),\n 'length', case when a.atttypid in (1042, 1043) and a.atttypmod > 4 then a.atttypmod - 4 end,\n 'precision', case when a.atttypid = 1700 and a.atttypmod > 4 then ((a.atttypmod - 4) >> 16) & 65535 end,\n 'scale', case when a.atttypid = 1700 and a.atttypmod > 4 then (a.atttypmod - 4) & 65535 end,\n 'checks', coalesce((\n select json_agg(pg_get_constraintdef(k.oid) order by k.conname)\n from pg_catalog.pg_constraint k\n where k.conrelid = c.oid and k.contype = 'c' and a.attnum = any(k.conkey)\n ), '[]'::json)\n ) order by c.relname, a.attnum)\n from pg_catalog.pg_class c\n join pg_catalog.pg_namespace n on n.oid = c.relnamespace\n join pg_catalog.pg_attribute a on a.attrelid = c.oid and a.attnum > 0 and not a.attisdropped\n where n.nspname = 'public' and c.relkind in ('r', 'p')\n ), '{}'::json)\n) as snapshot";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// check:limits (docs/spec.md §3; form-validation standard §4.1): the query
|
|
2
|
+
// whose one row is a snapshot of every column's size in `public`. Read-only
|
|
3
|
+
// and through pg_catalog only, so it works as sb_query's restricted role
|
|
4
|
+
// (which can't read supabase_migrations). Claude runs it with sb_query right
|
|
5
|
+
// after migrations apply (env=staging, and env=production once production has
|
|
6
|
+
// a database) and saves the result with `workbench-supabase limits-save`,
|
|
7
|
+
// which stamps it with the repo's migrations; check:limits compares it with
|
|
8
|
+
// the app's limits register in Checks, which have no database access.
|
|
9
|
+
export const LIMITS_SQL = `select json_build_object(
|
|
10
|
+
'format', 1,
|
|
11
|
+
'columns', coalesce((
|
|
12
|
+
select json_object_agg(c.relname || '.' || a.attname, json_build_object(
|
|
13
|
+
'type', format_type(a.atttypid, null),
|
|
14
|
+
'length', case when a.atttypid in (1042, 1043) and a.atttypmod > 4 then a.atttypmod - 4 end,
|
|
15
|
+
'precision', case when a.atttypid = 1700 and a.atttypmod > 4 then ((a.atttypmod - 4) >> 16) & 65535 end,
|
|
16
|
+
'scale', case when a.atttypid = 1700 and a.atttypmod > 4 then (a.atttypmod - 4) & 65535 end,
|
|
17
|
+
'checks', coalesce((
|
|
18
|
+
select json_agg(pg_get_constraintdef(k.oid) order by k.conname)
|
|
19
|
+
from pg_catalog.pg_constraint k
|
|
20
|
+
where k.conrelid = c.oid and k.contype = 'c' and a.attnum = any(k.conkey)
|
|
21
|
+
), '[]'::json)
|
|
22
|
+
) order by c.relname, a.attnum)
|
|
23
|
+
from pg_catalog.pg_class c
|
|
24
|
+
join pg_catalog.pg_namespace n on n.oid = c.relnamespace
|
|
25
|
+
join pg_catalog.pg_attribute a on a.attrelid = c.oid and a.attnum > 0 and not a.attisdropped
|
|
26
|
+
where n.nspname = 'public' and c.relkind in ('r', 'p')
|
|
27
|
+
), '{}'::json)
|
|
28
|
+
) as snapshot`;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mccaskie-labs/workbench-supabase",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Supabase clients for Workbench apps (browser, server, the caller's own token, and a service client that logs its reason), plus check:migrations
|
|
3
|
+
"version": "0.4.1",
|
|
4
|
+
"description": "Supabase clients for Workbench apps (browser, server, the caller's own token, and a service client that logs its reason), plus check:migrations, check:prod-schema and check:limits.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"repository": {
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"@mccaskie-labs/workbench-config": "^0.1.3",
|
|
42
|
+
"@mccaskie-labs/workbench-forms": "^0.2.0",
|
|
42
43
|
"@supabase/ssr": "^0.12.7",
|
|
43
44
|
"@supabase/supabase-js": "^2.119.0"
|
|
44
45
|
},
|