@tekyzinc/gsd-t 5.3.10 → 5.4.11
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 +36 -0
- package/README.md +1 -1
- package/bin/gsd-t-schema-id-check.cjs +246 -0
- package/bin/gsd-t-verify-gate.cjs +2 -0
- package/bin/gsd-t.js +8 -0
- package/package.json +1 -1
- package/templates/CLAUDE-global.md +29 -0
- package/templates/stacks/_comparison.md +134 -0
- package/templates/stacks/firebase.md +31 -0
- package/templates/stacks/neo4j.md +12 -0
- package/templates/stacks/postgresql.md +42 -4
- package/templates/stacks/prisma.md +12 -5
- package/templates/stacks/supabase.md +40 -0
- package/templates/stacks/typescript.md +25 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,42 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to GSD-T are documented here. Updated with each release.
|
|
4
4
|
|
|
5
|
+
## [5.4.11] - 2026-07-27
|
|
6
|
+
|
|
7
|
+
### Fixed — schema-id gate never reached projects (propagation gap)
|
|
8
|
+
|
|
9
|
+
v5.4.10 wired the `schema-id` verify gate into `gsd-t-verify-gate.cjs`, which dispatches to `bin/gsd-t-schema-id-check.cjs` by absolute path — but the new file was in neither `GLOBAL_BIN_TOOLS` nor `PROJECT_BIN_TOOLS`, so `update-all` propagated the *caller* to all 32 projects without the *callee*. Every project would have hit ENOENT on the check. Caught by verifying the file actually landed on disk in three sample projects rather than trusting the "copied 1 bin tool(s)" report.
|
|
10
|
+
|
|
11
|
+
This is the third occurrence of the same class (M89 research gate, M99 graph store-resolver) — a tool added to the source and wired into a caller, but never added to a propagation list.
|
|
12
|
+
|
|
13
|
+
- `bin/gsd-t.js`: added `gsd-t-schema-id-check.cjs` to both `GLOBAL_BIN_TOOLS` and `PROJECT_BIN_TOOLS`, with comments naming the dispatch dependency.
|
|
14
|
+
- `.gsd-t/contracts/graph-metrics-contract.md`: line citation for `doMetrics` refreshed (4907 → 4915) after the insertion shifted it — enforced by the M99 T6 contract-line test.
|
|
15
|
+
|
|
16
|
+
Note: `templates/stacks/_comparison.md` needed no propagation entry — stack rules are read from the installed package at spawn time (`gsd-t-task-brief.js`), not copied per-project. Verified live in the published package.
|
|
17
|
+
|
|
18
|
+
## [5.4.10] - 2026-07-27
|
|
19
|
+
|
|
20
|
+
### Added — Integer-identity primary keys + case-insensitive domain comparisons (two framework-wide code standards)
|
|
21
|
+
|
|
22
|
+
Two mandatory standards, both previously absent from GSD-T. **(1) Every NEW relational table gets a self-incrementing integer primary key named `id`**; API-exposed tables also get a separate UNIQUE `public_id` UUID. The integer wins inside the database (8 bytes vs 16, insert-ordered so the index stays compact, fast joins, readable in logs); the UUID protects the edge (a sequential id in a URL leaks row counts and lets anyone enumerate `/invoice/1`, `/invoice/2`). `postgresql.md` previously said the opposite — "UUID preferred over serial" — with a UUID GOOD example agents were copying. **(2) Comparing a domain string value against a literal is case-insensitive by default** (status, filter, tab, role, category, email, user-entered text), while identifiers, secrets, hashes, encoded values, Linux file paths, object keys, and env-var names stay case-sensitive — blanket insensitivity on a token or hash is a security defect. Origin: a real shipped bug where `filter !== 'Invoiced'` never matched a lowercase `'invoiced'`, producing two visible UI defects from one casing mismatch.
|
|
23
|
+
|
|
24
|
+
**Scope — NEW tables only.** A survey of all 32 registered projects found ~480 existing tables on UUID/cuid primary keys whose foreign keys already point at those UUIDs; changing an established primary key is a data migration and a Destructive Action Guard item, not an in-passing edit. No distributed-writes escape hatch was added — the same survey found zero multi-node or offline-first writers, so an unused exception path would be a speculative branch.
|
|
25
|
+
|
|
26
|
+
**Firestore and Neo4j are exempt, and the exemption is stated rather than silent.** Firestore has no sequence to increment — forcing one means a counter document that serializes every write in the collection, re-introducing the bottleneck the database exists to avoid. Neo4j has no rows, and its internal `id()` is reused after node deletion. Leaving those files silent would let an agent read the gap as an oversight and invent the counter-document anti-pattern.
|
|
27
|
+
|
|
28
|
+
- `bin/gsd-t-schema-id-check.cjs`: NEW deterministic verify gate (`schema-id`), FAIL-CLOSED. Inspects only schema files new or modified in the working tree, so established UUID-keyed repos pass untouched. Explicit no-op PASS (distinguishable from a wired-but-broken FAIL) when git is unavailable or no schema files changed.
|
|
29
|
+
- `bin/gsd-t-verify-gate.cjs`: wired `schema-id` into the Track 2 default plan.
|
|
30
|
+
- `templates/stacks/_comparison.md`: NEW universal rules file (`_` prefix → auto-injected into every project regardless of stack). Case-insensitive/case-sensitive split, SQL guidance with functional-index note, shared-constant rule, verification checklist.
|
|
31
|
+
- `templates/stacks/postgresql.md`: §1 naming + §2 schema design rewritten; GOOD/BAD examples and checklist updated; explicit scope note on non-retrofit.
|
|
32
|
+
- `templates/stacks/prisma.md`: `id Int @id @default(autoincrement())` mandatory + `publicId`; GOOD/BAD examples inverted (the old BAD example was the new rule).
|
|
33
|
+
- `templates/stacks/supabase.md`: NEW §6.5 — including that a `user_id` column referencing `auth.users` stays UUID because row-level security compares it against `auth.uid()`.
|
|
34
|
+
- `templates/stacks/firebase.md`, `templates/stacks/neo4j.md`: stated exemptions with the counter-document anti-pattern shown as BAD.
|
|
35
|
+
- `templates/stacks/typescript.md`: §6 extended — a fixed value set lives in ONE shared constant so the compiler catches mismatches (case-insensitivity only treats the symptom; a typo'd value still fails silently).
|
|
36
|
+
- `templates/CLAUDE-global.md` + live `~/.claude/CLAUDE.md`: both rules added and kept in sync, so they govern conversational work as well as spawned domain workers.
|
|
37
|
+
- `test/schema-id-check.test.js`: NEW — 11 regression tests covering all three Postgres identity spellings, UUID/text/composite rejection, nested-paren bodies, Prisma models, and the new-tables-only scope.
|
|
38
|
+
|
|
39
|
+
Migration: none required. Existing schemas are unaffected — the gate ignores committed tables, and no existing project is retrofitted.
|
|
40
|
+
|
|
5
41
|
## [5.3.10] - 2026-07-24
|
|
6
42
|
|
|
7
43
|
### Changed — Fable 5 removed; Opus tier upgraded to Opus 5 (claude-opus-5)
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# GSD-T: Contract-Driven Development for Claude Code
|
|
2
2
|
|
|
3
|
-
**v5.
|
|
3
|
+
**v5.4.11** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
|
|
4
4
|
|
|
5
5
|
**Eliminates context rot** — task-level fresh dispatch (one subagent per task, ~10-20% context each) means compaction never triggers.
|
|
6
6
|
**Compaction-proof debug loops** — `gsd-t headless --debug-loop` runs test-fix-retest cycles as separate `claude -p` sessions. A JSONL debug ledger persists all hypothesis/fix/learning history across fresh sessions. Anti-repetition preamble injection prevents retrying failed hypotheses. Escalation tiers (sonnet → opus → human) and a hard iteration ceiling enforced externally.
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// bin/gsd-t-schema-id-check.cjs
|
|
4
|
+
//
|
|
5
|
+
// The deterministic verify-gate lint for the integer-primary-key rule.
|
|
6
|
+
// FAIL-CLOSED for relational schemas; NO-OP PASS for projects with no schema.
|
|
7
|
+
//
|
|
8
|
+
// THE RULE (relational stacks only — postgresql / prisma / supabase / drizzle):
|
|
9
|
+
// every NEW table has a self-incrementing integer primary key named `id`.
|
|
10
|
+
//
|
|
11
|
+
// SCOPE — NEW tables only. A pre-existing table with a UUID primary key is NOT a
|
|
12
|
+
// failure: its foreign keys already point at that UUID, so changing the PK is a
|
|
13
|
+
// data migration + Destructive Action Guard item, never an in-passing edit. The
|
|
14
|
+
// gate therefore checks only tables whose defining file is UNCOMMITTED or was
|
|
15
|
+
// added in the working tree — i.e. tables this run is introducing. A repo with an
|
|
16
|
+
// established UUID-keyed schema passes untouched.
|
|
17
|
+
//
|
|
18
|
+
// NOT APPLICABLE — firestore/firebase (document store: no sequence to increment;
|
|
19
|
+
// a counter document serializes writes) and neo4j (no rows; internal id() is
|
|
20
|
+
// reused after deletion). Those stacks state the exemption in their own rules
|
|
21
|
+
// file; this gate never inspects them.
|
|
22
|
+
//
|
|
23
|
+
// Zero external npm runtime deps — fs/path/child_process only.
|
|
24
|
+
|
|
25
|
+
const fs = require("fs");
|
|
26
|
+
const path = require("path");
|
|
27
|
+
const { execFileSync } = require("child_process");
|
|
28
|
+
|
|
29
|
+
// ─── SQL: CREATE TABLE detection ─────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
// A table body is scanned for a column that is BOTH an integer type AND carries
|
|
32
|
+
// an identity/serial default AND is the primary key. Postgres spells this three
|
|
33
|
+
// ways; all three are accepted.
|
|
34
|
+
const SQL_IDENTITY_PK =
|
|
35
|
+
/\bid\b[^,]*?\b(?:bigint|integer|int|smallint)\b[^,]*?\bgenerated\s+(?:always|by\s+default)\s+as\s+identity\b[^,]*?\bprimary\s+key\b/is;
|
|
36
|
+
const SQL_SERIAL_PK = /\bid\b\s+(?:big|small)?serial\b[^,]*?\bprimary\s+key\b/is;
|
|
37
|
+
// Table-level constraint form: id BIGINT GENERATED ... , PRIMARY KEY (id)
|
|
38
|
+
const SQL_IDENTITY_COL =
|
|
39
|
+
/\bid\b[^,]*?\b(?:bigint|integer|int|smallint)\b[^,]*?\b(?:generated\s+(?:always|by\s+default)\s+as\s+identity|serial)\b/is;
|
|
40
|
+
const SQL_TABLE_PK_ID = /\bprimary\s+key\s*\(\s*"?id"?\s*\)/is;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Extract (tableName, body) for each CREATE TABLE in a SQL string.
|
|
44
|
+
* Brace-matched on parentheses so nested type parens (numeric(10,2)) don't
|
|
45
|
+
* truncate the body.
|
|
46
|
+
*/
|
|
47
|
+
function extractSqlTables(sql) {
|
|
48
|
+
const out = [];
|
|
49
|
+
const re = /create\s+table\s+(?:if\s+not\s+exists\s+)?([`"\[]?[\w.]+[`"\]]?)\s*\(/gi;
|
|
50
|
+
let m;
|
|
51
|
+
while ((m = re.exec(sql)) !== null) {
|
|
52
|
+
const name = m[1].replace(/[`"\[\]]/g, "");
|
|
53
|
+
let depth = 1;
|
|
54
|
+
let i = re.lastIndex;
|
|
55
|
+
while (i < sql.length && depth > 0) {
|
|
56
|
+
const ch = sql[i];
|
|
57
|
+
if (ch === "(") depth++;
|
|
58
|
+
else if (ch === ")") depth--;
|
|
59
|
+
i++;
|
|
60
|
+
}
|
|
61
|
+
out.push({ name, body: sql.slice(re.lastIndex, i - 1) });
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function sqlTableHasIntegerIdPk(body) {
|
|
67
|
+
if (SQL_IDENTITY_PK.test(body)) return true;
|
|
68
|
+
if (SQL_SERIAL_PK.test(body)) return true;
|
|
69
|
+
if (SQL_IDENTITY_COL.test(body) && SQL_TABLE_PK_ID.test(body)) return true;
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── Prisma: model detection ─────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
const PRISMA_INT_ID = /\bid\s+Int\s+@id\s+@default\s*\(\s*autoincrement\s*\(\s*\)\s*\)/;
|
|
76
|
+
const PRISMA_BIGINT_ID = /\bid\s+BigInt\s+@id\s+@default\s*\(\s*autoincrement\s*\(\s*\)\s*\)/;
|
|
77
|
+
|
|
78
|
+
/** Extract (modelName, body) for each `model X { ... }` block. */
|
|
79
|
+
function extractPrismaModels(src) {
|
|
80
|
+
const out = [];
|
|
81
|
+
const re = /^\s*model\s+(\w+)\s*\{/gm;
|
|
82
|
+
let m;
|
|
83
|
+
while ((m = re.exec(src)) !== null) {
|
|
84
|
+
let depth = 1;
|
|
85
|
+
let i = re.lastIndex;
|
|
86
|
+
while (i < src.length && depth > 0) {
|
|
87
|
+
const ch = src[i];
|
|
88
|
+
if (ch === "{") depth++;
|
|
89
|
+
else if (ch === "}") depth--;
|
|
90
|
+
i++;
|
|
91
|
+
}
|
|
92
|
+
out.push({ name: m[1], body: src.slice(re.lastIndex, i - 1) });
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function prismaModelHasIntegerIdPk(body) {
|
|
98
|
+
return PRISMA_INT_ID.test(body) || PRISMA_BIGINT_ID.test(body);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// A Prisma block that is only a relation join table declared with @@id([a, b])
|
|
102
|
+
// still needs its own integer id under the rule — no exemption. But a `view`
|
|
103
|
+
// or `enum` block is not a model and is never reached (regex matches `model`).
|
|
104
|
+
|
|
105
|
+
// ─── Which files are NEW in this working tree ────────────────────────────────
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Files added/modified relative to HEAD, per git. The rule governs NEW tables,
|
|
109
|
+
* so only these files are inspected.
|
|
110
|
+
*
|
|
111
|
+
* Returns null when git is unavailable or the directory is not a repo — the
|
|
112
|
+
* caller turns that into a NO-OP PASS with an explicit note rather than
|
|
113
|
+
* silently scanning everything (which would fail every legacy schema).
|
|
114
|
+
*/
|
|
115
|
+
function changedFiles(projectDir) {
|
|
116
|
+
try {
|
|
117
|
+
const out = execFileSync(
|
|
118
|
+
"git",
|
|
119
|
+
["status", "--porcelain=v1", "--untracked-files=all"],
|
|
120
|
+
{ cwd: projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
|
|
121
|
+
);
|
|
122
|
+
const files = [];
|
|
123
|
+
for (const line of out.split("\n")) {
|
|
124
|
+
if (!line.trim()) continue;
|
|
125
|
+
// porcelain v1: XY <path> (rename: XY <old> -> <new>)
|
|
126
|
+
const p = line.slice(3).trim();
|
|
127
|
+
const finalPath = p.includes(" -> ") ? p.split(" -> ").pop() : p;
|
|
128
|
+
files.push(finalPath.replace(/^"|"$/g, ""));
|
|
129
|
+
}
|
|
130
|
+
return files;
|
|
131
|
+
} catch (_) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const SQL_EXT = /\.sql$/i;
|
|
137
|
+
const PRISMA_EXT = /\.prisma$/i;
|
|
138
|
+
|
|
139
|
+
function isSchemaFile(rel) {
|
|
140
|
+
if (/node_modules|\.git\//.test(rel)) return false;
|
|
141
|
+
return SQL_EXT.test(rel) || PRISMA_EXT.test(rel);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function readSafe(p) {
|
|
145
|
+
try {
|
|
146
|
+
return fs.readFileSync(p, "utf8");
|
|
147
|
+
} catch (_) {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ─── Main check ──────────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
function check(projectDir) {
|
|
155
|
+
const failures = [];
|
|
156
|
+
const inspected = [];
|
|
157
|
+
|
|
158
|
+
const changed = changedFiles(projectDir);
|
|
159
|
+
if (changed === null) {
|
|
160
|
+
return {
|
|
161
|
+
ok: true,
|
|
162
|
+
check: "schema-id",
|
|
163
|
+
inspected: [],
|
|
164
|
+
failures: [],
|
|
165
|
+
note:
|
|
166
|
+
"no-op PASS: git unavailable or not a repository — cannot distinguish NEW tables from " +
|
|
167
|
+
"pre-existing ones, and the rule governs NEW tables only",
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const schemaFiles = changed.filter(isSchemaFile);
|
|
172
|
+
if (schemaFiles.length === 0) {
|
|
173
|
+
return {
|
|
174
|
+
ok: true,
|
|
175
|
+
check: "schema-id",
|
|
176
|
+
inspected: [],
|
|
177
|
+
failures: [],
|
|
178
|
+
note: "no-op PASS: no new or modified .sql/.prisma schema files in the working tree",
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
for (const rel of schemaFiles) {
|
|
183
|
+
const abs = path.join(projectDir, rel);
|
|
184
|
+
const src = readSafe(abs);
|
|
185
|
+
if (src === null) continue; // deleted file listed by git status
|
|
186
|
+
|
|
187
|
+
if (PRISMA_EXT.test(rel)) {
|
|
188
|
+
for (const model of extractPrismaModels(src)) {
|
|
189
|
+
inspected.push(`${rel}::model ${model.name}`);
|
|
190
|
+
if (!prismaModelHasIntegerIdPk(model.body)) {
|
|
191
|
+
failures.push(
|
|
192
|
+
`${rel}: model \`${model.name}\` has no self-incrementing integer primary key — ` +
|
|
193
|
+
`add \`id Int @id @default(autoincrement())\`. If this model is API-exposed, also add ` +
|
|
194
|
+
`\`publicId String @unique @default(uuid())\` and expose that instead of the integer id.`
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
} else {
|
|
199
|
+
for (const table of extractSqlTables(src)) {
|
|
200
|
+
inspected.push(`${rel}::table ${table.name}`);
|
|
201
|
+
if (!sqlTableHasIntegerIdPk(table.body)) {
|
|
202
|
+
failures.push(
|
|
203
|
+
`${rel}: table \`${table.name}\` has no self-incrementing integer primary key — ` +
|
|
204
|
+
`add \`id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY\`. If this table is ` +
|
|
205
|
+
`API-exposed, also add \`public_id UUID NOT NULL DEFAULT gen_random_uuid()\` with a ` +
|
|
206
|
+
`UNIQUE constraint and expose that instead of the integer id.`
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
ok: failures.length === 0,
|
|
215
|
+
check: "schema-id",
|
|
216
|
+
inspected,
|
|
217
|
+
failures,
|
|
218
|
+
note:
|
|
219
|
+
inspected.length === 0
|
|
220
|
+
? "no-op PASS: changed schema files contain no CREATE TABLE / model definitions"
|
|
221
|
+
: undefined,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function parseArgs(argv) {
|
|
226
|
+
const out = { projectDir: "." };
|
|
227
|
+
for (let i = 0; i < argv.length; i++) {
|
|
228
|
+
if (argv[i] === "--project") out.projectDir = argv[++i] || ".";
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
module.exports = {
|
|
234
|
+
check,
|
|
235
|
+
extractSqlTables,
|
|
236
|
+
sqlTableHasIntegerIdPk,
|
|
237
|
+
extractPrismaModels,
|
|
238
|
+
prismaModelHasIntegerIdPk,
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
if (require.main === module) {
|
|
242
|
+
const { projectDir } = parseArgs(process.argv.slice(2));
|
|
243
|
+
const result = check(projectDir);
|
|
244
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
245
|
+
process.exit(result.ok ? 0 : 1);
|
|
246
|
+
}
|
|
@@ -309,6 +309,8 @@ function _detectDefaultTrack2(projectDir, notes) {
|
|
|
309
309
|
|
|
310
310
|
plan.push({ id: 'env-registry', cmd: 'node', args: [path.join(__dirname, 'gsd-t-env-registry-check.cjs'), '--project', projectDir], timeoutMs: 30000 }); // M102 D3: no-secret-in-registry + rule-without-table gate, FAIL-CLOSED
|
|
311
311
|
|
|
312
|
+
plan.push({ id: 'schema-id', cmd: 'node', args: [path.join(__dirname, 'gsd-t-schema-id-check.cjs'), '--project', projectDir], timeoutMs: 30000 }); // integer-identity PK on every NEW relational table, FAIL-CLOSED (no-op PASS when no new schema files)
|
|
313
|
+
|
|
312
314
|
// secrets — gitleaks (PATH detection deferred to runtime)
|
|
313
315
|
if (_hasOnPath('gitleaks')) {
|
|
314
316
|
plan.push({
|
package/bin/gsd-t.js
CHANGED
|
@@ -1559,6 +1559,10 @@ const GLOBAL_BIN_TOOLS = [
|
|
|
1559
1559
|
"gsd-t-loop-ledger.cjs",
|
|
1560
1560
|
// Backlog #40 — deterministic archive+sweep of a completed milestone's domain dirs.
|
|
1561
1561
|
"gsd-t-archive-domains.cjs",
|
|
1562
|
+
// v5.4.10 — integer-identity primary-key gate. gsd-t-verify-gate.cjs dispatches to
|
|
1563
|
+
// this by absolute path, so it MUST ship wherever the verify gate ships or the
|
|
1564
|
+
// schema-id check throws ENOENT. Same class as the M99 store-resolver omission below.
|
|
1565
|
+
"gsd-t-schema-id-check.cjs",
|
|
1562
1566
|
];
|
|
1563
1567
|
|
|
1564
1568
|
function installGlobalBinTools() {
|
|
@@ -2939,6 +2943,10 @@ const PROJECT_BIN_TOOLS = [
|
|
|
2939
2943
|
"cli-preflight.cjs", "parallel-cli.cjs", "parallel-cli-tee.cjs",
|
|
2940
2944
|
"gsd-t-context-brief.cjs",
|
|
2941
2945
|
"gsd-t-verify-gate.cjs", "gsd-t-verify-gate-judge.cjs",
|
|
2946
|
+
// v5.4.10 — integer-identity primary-key gate. gsd-t-verify-gate.cjs dispatches to
|
|
2947
|
+
// it via an absolute path in the Track 2 plan, so a project that has the verify gate
|
|
2948
|
+
// but NOT this file gets an ENOENT on every verify. Ships alongside the gate itself.
|
|
2949
|
+
"gsd-t-schema-id-check.cjs",
|
|
2942
2950
|
// M82 — Competition Mode judge + its disjointness oracle dependency, so a
|
|
2943
2951
|
// project's gsd-t-phase workflow can score candidate partitions via the
|
|
2944
2952
|
// project-local bin (runCli prefers bin/<tool>.cjs over the global binary).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tekyzinc/gsd-t",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.4.11",
|
|
4
4
|
"description": "GSD-T: Contract-Driven Development for Claude Code — 54 slash commands with headless-by-default workflow spawning, unattended supervisor relay with event stream, graph-powered code analysis, real-time agent dashboard, task telemetry, doc-ripple enforcement, backlog management, impact analysis, test sync, milestone archival, and PRD generation",
|
|
5
5
|
"author": "Tekyz, Inc.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -569,6 +569,35 @@ Skip the hint if auto-advancing (Level 3 mid-wave) — only show when the user n
|
|
|
569
569
|
- Functions under 30 lines — split if longer
|
|
570
570
|
- Files under 200 lines — create new modules if needed
|
|
571
571
|
- Enums for state management and fixed option sets
|
|
572
|
+
- **Fixed value sets live in ONE shared constant** — every comparison references it, never a retyped literal (a second literal is where casing/typo mismatches hide)
|
|
573
|
+
|
|
574
|
+
## Comparisons — case-insensitive by default (MANDATORY)
|
|
575
|
+
|
|
576
|
+
**Comparing a domain string VALUE against a literal is CASE-INSENSITIVE unless the user specifically directs otherwise.** Domain values (status, filter, tab, mode, role, category, email, user-entered text) cross boundaries — database, API, URL, form — and each is free to change the casing. A literal comparison silently returns `false` and the feature quietly does nothing.
|
|
577
|
+
|
|
578
|
+
```
|
|
579
|
+
CASE-INSENSITIVE: statuses, filters, tabs, modes, roles, categories, emails,
|
|
580
|
+
user-entered values, route/query params carrying any of these
|
|
581
|
+
CASE-SENSITIVE: passwords, tokens, API keys, hashes, signatures (case-insensitive
|
|
582
|
+
here is a SECURITY defect); base64/JWT/hex/uuid-as-string/git SHA
|
|
583
|
+
(case IS data); Linux file paths; object/property keys; DOM tag
|
|
584
|
+
and prop names; env-var NAMES; branch names
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
Guard for absent values: `(filter || '').toLowerCase() !== 'invoiced'`. In SQL: `lower(col) = lower($1)` with a functional index on `lower(col)` when queried at volume.
|
|
588
|
+
|
|
589
|
+
Full rule + worked examples: `templates/stacks/_comparison.md` (universal — auto-injected into every project).
|
|
590
|
+
|
|
591
|
+
## Database schema — every new table gets a self-incrementing integer ID (MANDATORY)
|
|
592
|
+
|
|
593
|
+
**Every NEW relational table has a self-incrementing integer primary key named `id`.** API-exposed tables ALSO get a separate `public_id` UUID (unique, indexed) — expose that externally, never the integer id.
|
|
594
|
+
|
|
595
|
+
- **Why the integer inside** — 8 bytes vs 16, sorts in insert order so the index stays compact, joins fast, readable in a log. A UUID primary key scatters index writes and bloats every foreign key pointing at it.
|
|
596
|
+
- **Why the UUID at the edge** — a sequential id in a URL leaks row counts and lets anyone walk `/invoice/1`, `/invoice/2` looking for other people's data.
|
|
597
|
+
- **NEW tables only.** Existing UUID-keyed tables are NOT retrofitted — their foreign keys already point at the UUID, so changing the PK is a data migration and a **Destructive Action Guard** item requiring explicit user approval.
|
|
598
|
+
- **Does NOT apply to Firestore or Neo4j** — deliberate, not an oversight. Firestore has no sequence to increment (forcing one means a counter document that serializes every write); Neo4j has no rows, and its internal `id()` is reused after deletion. Each stack file states this.
|
|
599
|
+
|
|
600
|
+
Enforced mechanically by `bin/gsd-t-schema-id-check.cjs` (verify-gate `schema-id`, FAIL-CLOSED; no-op PASS when a run adds no schema files). Full rules: `templates/stacks/postgresql.md` §2, `prisma.md` §1, `supabase.md` §6.5.
|
|
572
601
|
|
|
573
602
|
## Naming
|
|
574
603
|
```
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# Comparison Standards (Universal)
|
|
2
|
+
|
|
3
|
+
These rules are MANDATORY. Violations fail the task. No exceptions.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. Domain String Comparisons Are Case-Insensitive by Default
|
|
8
|
+
|
|
9
|
+
**Comparing a domain string VALUE against a literal is case-insensitive unless the user has
|
|
10
|
+
specifically directed otherwise.**
|
|
11
|
+
|
|
12
|
+
A domain value is a string that names something in the business world: a status, a filter, a tab, a
|
|
13
|
+
mode, a role, a category, a user-entered value, an email address. These cross system boundaries —
|
|
14
|
+
database, API, URL, form input, config — and each boundary is free to change the casing. A literal
|
|
15
|
+
comparison silently returns `false` and the feature just quietly does nothing.
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
CASE-INSENSITIVE (domain value vs literal):
|
|
19
|
+
├── Statuses, filters, tabs, modes, view names
|
|
20
|
+
├── Roles, categories, types, enum-ish values
|
|
21
|
+
├── Email addresses (the local part is technically case-sensitive per spec,
|
|
22
|
+
│ but no real mail provider treats it that way — compare lowercased)
|
|
23
|
+
├── User-entered text being matched against known values
|
|
24
|
+
└── Query-string and route params carrying any of the above
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**GOOD**
|
|
28
|
+
```ts
|
|
29
|
+
filter.toLowerCase() !== 'invoiced'
|
|
30
|
+
status.toLowerCase() === 'pending'
|
|
31
|
+
user.email.toLowerCase() === input.email.toLowerCase()
|
|
32
|
+
row.category?.toLowerCase() === selected.toLowerCase()
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
**BAD** — a real bug, shipped:
|
|
36
|
+
```ts
|
|
37
|
+
// TILE_FILTERS maps the Invoiced tile to lowercase 'invoiced'.
|
|
38
|
+
// This compares against 'Invoiced'. It never matched. The "+ Add Order" button
|
|
39
|
+
// appeared on a tab where hand-adding an order is impossible, and the widening
|
|
40
|
+
// branch below pulled in orders that made an unrelated button appear too —
|
|
41
|
+
// two visible defects, one casing mismatch.
|
|
42
|
+
onAddOrder={view === 'orders' && filter !== 'Invoiced' ? handleAdd : undefined}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Guard against null/undefined when lowercasing a value that may be absent:
|
|
46
|
+
```ts
|
|
47
|
+
(filter || '').toLowerCase() !== 'invoiced' // or filter?.toLowerCase()
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## 2. Identifiers, Secrets, and Encoded Values Stay Case-Sensitive
|
|
53
|
+
|
|
54
|
+
Case-insensitivity is a **default for domain values, not a blanket rule**. Applying it to the
|
|
55
|
+
following is a defect — and for the first group, a security defect:
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
ALWAYS CASE-SENSITIVE:
|
|
59
|
+
├── Passwords, tokens, API keys, session IDs, signatures, hashes
|
|
60
|
+
│ (case-insensitive matching here weakens the secret — a real vulnerability)
|
|
61
|
+
├── Encoded values: base64, JWTs, hex digests, cuid/uuid-as-string, git SHAs
|
|
62
|
+
│ (case IS data — aB and Ab are different values)
|
|
63
|
+
├── File paths and filenames on Linux (Makefile ≠ makefile — different files)
|
|
64
|
+
├── Object and property keys (obj.userId ≠ obj.userid — different properties)
|
|
65
|
+
├── DOM/JSX tag names, prop names, CSS custom properties
|
|
66
|
+
├── Environment variable NAMES, git branch names, DNS record values
|
|
67
|
+
└── Anything compared for cryptographic equality
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**BAD** — a security defect:
|
|
71
|
+
```ts
|
|
72
|
+
if (providedToken.toLowerCase() === storedToken.toLowerCase()) grantAccess();
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**GOOD**
|
|
76
|
+
```ts
|
|
77
|
+
if (providedToken === storedToken) grantAccess(); // exact, case-sensitive
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## 3. Database Comparisons
|
|
83
|
+
|
|
84
|
+
The same split applies in SQL. A domain-value comparison is case-insensitive; an identifier or
|
|
85
|
+
token comparison is exact.
|
|
86
|
+
|
|
87
|
+
```sql
|
|
88
|
+
-- GOOD — domain value, case-insensitive
|
|
89
|
+
WHERE lower(status) = 'pending'
|
|
90
|
+
WHERE lower(email) = lower($1)
|
|
91
|
+
|
|
92
|
+
-- GOOD — citext column, or a functional index to keep it fast
|
|
93
|
+
CREATE INDEX idx_orders_status_lower ON orders (lower(status));
|
|
94
|
+
|
|
95
|
+
-- GOOD — identifier, exact
|
|
96
|
+
WHERE public_id = $1
|
|
97
|
+
WHERE api_key_hash = $1
|
|
98
|
+
|
|
99
|
+
-- BAD — domain value compared exactly; breaks when casing drifts
|
|
100
|
+
WHERE status = 'Pending'
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Note: `lower(col) = ...` cannot use a plain index on `col`. Add a functional index on
|
|
104
|
+
`lower(col)`, or use a case-insensitive column type, when the column is queried at volume.
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## 4. Prefer a Shared Constant Over Any String Literal
|
|
109
|
+
|
|
110
|
+
Case-insensitivity is the safety net. The structural fix is that a fixed set of values exists in
|
|
111
|
+
**one** place and every comparison references it — then a mismatch is caught by the compiler
|
|
112
|
+
instead of by a user.
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
// The bug above cannot occur when both sites reference the same constant:
|
|
116
|
+
export const ORDER_FILTER = { ORDERS: 'orders', INVOICED: 'invoiced' } as const;
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Use both mechanisms. The shared constant catches typos and casing at build time; case-insensitive
|
|
120
|
+
comparison catches the same class of bug at the boundaries a constant can't reach — data arriving
|
|
121
|
+
from a database, an API response, a URL, or a user.
|
|
122
|
+
|
|
123
|
+
See `typescript.md` §6 for the typed form, and `postgresql.md`/`prisma.md` for database enums.
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Comparison Verification Checklist
|
|
128
|
+
|
|
129
|
+
- [ ] Every domain-value comparison (status/filter/tab/role/category/email) is case-insensitive
|
|
130
|
+
- [ ] No `.toLowerCase()` applied to a token, hash, signature, key, or encoded value
|
|
131
|
+
- [ ] Lowercasing guards against null/undefined (`(x || '').toLowerCase()`)
|
|
132
|
+
- [ ] Fixed value sets are declared once and referenced — not retyped as literals
|
|
133
|
+
- [ ] SQL domain-value filters use `lower()` (with a functional index if queried at volume)
|
|
134
|
+
- [ ] File paths, object keys, and env-var names compared exactly
|
|
@@ -44,9 +44,40 @@ MANDATORY:
|
|
|
44
44
|
├── Embedded objects for small, bounded data that's always read together
|
|
45
45
|
├── Store document IDs as fields when needed for queries
|
|
46
46
|
├── Use server timestamps: serverTimestamp() — not client Date.now()
|
|
47
|
+
├── Use Firestore's auto-generated document IDs — NEVER build a sequential counter (see below)
|
|
47
48
|
└── NEVER create deeply nested subcollection hierarchies (max 2-3 levels)
|
|
48
49
|
```
|
|
49
50
|
|
|
51
|
+
**The self-incrementing-integer-ID rule does NOT apply to Firestore.** This is deliberate, not an
|
|
52
|
+
oversight — the GSD-T relational stacks (postgresql, prisma, supabase) mandate an integer identity
|
|
53
|
+
primary key on every table. Firestore cannot provide one:
|
|
54
|
+
|
|
55
|
+
- A self-incrementing ID requires every writer to agree on "what was the last number", which means
|
|
56
|
+
one machine must be the authority and every insert must wait its turn. Firestore is built to
|
|
57
|
+
spread writes across many machines precisely to avoid that.
|
|
58
|
+
- Forcing it means a single counter document that every write locks and updates — capping the
|
|
59
|
+
collection's write rate at roughly one per second and re-introducing the bottleneck the database
|
|
60
|
+
exists to avoid.
|
|
61
|
+
|
|
62
|
+
Firestore's auto-generated IDs need no coordination and cannot collide between writers. Use them.
|
|
63
|
+
|
|
64
|
+
**BAD** — the counter-document anti-pattern:
|
|
65
|
+
```typescript
|
|
66
|
+
// NEVER — serializes every write in the collection behind one document
|
|
67
|
+
const counterRef = db.doc('counters/orders');
|
|
68
|
+
await db.runTransaction(async (tx) => {
|
|
69
|
+
const snap = await tx.get(counterRef);
|
|
70
|
+
const next = snap.data().value + 1;
|
|
71
|
+
tx.update(counterRef, { value: next });
|
|
72
|
+
tx.set(db.doc(`orders/${next}`), orderData);
|
|
73
|
+
});
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**GOOD** — let Firestore assign the ID:
|
|
77
|
+
```typescript
|
|
78
|
+
await db.collection('orders').add(orderData); // auto-ID, no coordination, no contention
|
|
79
|
+
```
|
|
80
|
+
|
|
50
81
|
**GOOD**
|
|
51
82
|
```typescript
|
|
52
83
|
// User document — embedded small data
|
|
@@ -15,9 +15,21 @@ MANDATORY:
|
|
|
15
15
|
├── Properties: camelCase (firstName, createdAt)
|
|
16
16
|
├── Store properties on the node or relationship that "owns" them
|
|
17
17
|
├── Relationships ALWAYS have a direction — even if queried bidirectionally
|
|
18
|
+
├── Every node carries an application-assigned `id` property with a UNIQUE constraint (see §3)
|
|
19
|
+
├── NEVER persist or expose Neo4j's internal id() — it is reused after deletion (see below)
|
|
18
20
|
└── NEVER use relationships as nodes (reify only when the relationship needs its own relationships)
|
|
19
21
|
```
|
|
20
22
|
|
|
23
|
+
**The self-incrementing-integer-ID rule does NOT apply to Neo4j.** This is deliberate, not an
|
|
24
|
+
oversight — the GSD-T relational stacks (postgresql, prisma, supabase) mandate an integer identity
|
|
25
|
+
primary key on every table. Neo4j has no tables and no rows to number: it stores nodes and the
|
|
26
|
+
relationships between them, so there is nothing for a sequence to attach to.
|
|
27
|
+
|
|
28
|
+
Neo4j does expose an internal `id()` per node, but it is **not a stable identifier** — delete a node
|
|
29
|
+
and the number is reused by a different node later. Anything that stored the old value now points at
|
|
30
|
+
the wrong entity. Use an application-assigned `id` property with a uniqueness constraint instead
|
|
31
|
+
(§3 Indexing and Constraints).
|
|
32
|
+
|
|
21
33
|
**GOOD**
|
|
22
34
|
```cypher
|
|
23
35
|
(:User {id: "u-123", name: "Jane", email: "jane@example.com"})
|
|
@@ -10,7 +10,8 @@ These rules are MANDATORY. Violations fail the task. No exceptions.
|
|
|
10
10
|
MANDATORY:
|
|
11
11
|
├── Tables: snake_case, plural (users, order_items)
|
|
12
12
|
├── Columns: snake_case, singular (first_name, created_at)
|
|
13
|
-
├── Primary keys: id
|
|
13
|
+
├── Primary keys: id — ALWAYS a self-incrementing integer identity (see §2)
|
|
14
|
+
├── Public identifiers: public_id UUID — only on API-exposed tables (see §2)
|
|
14
15
|
├── Foreign keys: {referenced_table_singular}_id (user_id, order_id)
|
|
15
16
|
├── Indexes: idx_{table}_{columns} (idx_users_email)
|
|
16
17
|
├── Constraints: {type}_{table}_{columns} (uq_users_email, fk_orders_user_id)
|
|
@@ -24,29 +25,64 @@ MANDATORY:
|
|
|
24
25
|
|
|
25
26
|
```
|
|
26
27
|
MANDATORY:
|
|
27
|
-
├──
|
|
28
|
+
├── EVERY table has a self-incrementing integer primary key named id — NO EXCEPTIONS
|
|
29
|
+
│ BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY
|
|
30
|
+
│ NEVER a UUID primary key. NEVER a natural/composite key as the PK.
|
|
31
|
+
├── API-exposed tables ALSO get public_id UUID NOT NULL DEFAULT gen_random_uuid()
|
|
32
|
+
│ with a UNIQUE constraint — expose public_id externally, NEVER the integer id
|
|
33
|
+
├── Every table has: created_at (timestamptz DEFAULT now()), updated_at
|
|
28
34
|
├── Use timestamptz — NEVER timestamp without time zone
|
|
29
|
-
├── Use UUID for primary keys in distributed or API-exposed systems
|
|
30
35
|
├── Use appropriate types: text (not varchar), numeric (not float for money)
|
|
31
36
|
├── Add NOT NULL constraints by default — allow NULL only with explicit reason
|
|
32
37
|
├── Foreign keys with ON DELETE policy (CASCADE, SET NULL, or RESTRICT — choose deliberately)
|
|
33
38
|
└── NEVER store JSON when a proper relational schema exists — use jsonb only for truly unstructured data
|
|
34
39
|
```
|
|
35
40
|
|
|
41
|
+
**Why an integer PK plus a separate public UUID** — the two identifiers do different jobs:
|
|
42
|
+
|
|
43
|
+
- **Integer `id` inside the database** — 8 bytes (vs 16 for UUID), sorts in insert order so the
|
|
44
|
+
index stays compact, joins fast, and is readable in a log (`order 4471`). A UUID primary key
|
|
45
|
+
scatters index writes and bloats every foreign key pointing at it.
|
|
46
|
+
- **UUID `public_id` at the edge** — a sequential ID in a URL leaks your row count and lets anyone
|
|
47
|
+
walk `/invoice/1`, `/invoice/2` looking for other people's data. The public column removes that
|
|
48
|
+
without giving up the storage and join win.
|
|
49
|
+
|
|
36
50
|
**GOOD**
|
|
37
51
|
```sql
|
|
38
52
|
CREATE TABLE users (
|
|
39
|
-
id
|
|
53
|
+
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
54
|
+
public_id UUID NOT NULL DEFAULT gen_random_uuid(),
|
|
40
55
|
email TEXT NOT NULL,
|
|
41
56
|
display_name TEXT NOT NULL,
|
|
42
57
|
role user_role NOT NULL DEFAULT 'viewer',
|
|
43
58
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
|
44
59
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
45
60
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
61
|
+
CONSTRAINT uq_users_public_id UNIQUE (public_id),
|
|
46
62
|
CONSTRAINT uq_users_email UNIQUE (email)
|
|
47
63
|
);
|
|
48
64
|
```
|
|
49
65
|
|
|
66
|
+
**BAD**
|
|
67
|
+
```sql
|
|
68
|
+
CREATE TABLE users (
|
|
69
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- UUID as PK: scattered index writes,
|
|
70
|
+
email TEXT NOT NULL -- 16-byte FKs, unreadable in logs
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
CREATE TABLE order_items (
|
|
74
|
+
order_id BIGINT NOT NULL, -- composite natural key as PK:
|
|
75
|
+
sku TEXT NOT NULL, -- no stable single-column identity
|
|
76
|
+
PRIMARY KEY (order_id, sku)
|
|
77
|
+
);
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**Scope** — this rule governs NEW tables. Existing tables with UUID primary keys are NOT
|
|
81
|
+
retrofitted: their foreign keys already point at the UUID, so adding an integer id without
|
|
82
|
+
repointing every referencing table leaves two competing identifier systems. Changing an existing
|
|
83
|
+
table's primary key is a data migration and a Destructive Action Guard item — it requires explicit
|
|
84
|
+
user approval, never an in-passing change.
|
|
85
|
+
|
|
50
86
|
---
|
|
51
87
|
|
|
52
88
|
## 3. Migrations
|
|
@@ -214,6 +250,8 @@ NEVER:
|
|
|
214
250
|
## PostgreSQL Verification Checklist
|
|
215
251
|
|
|
216
252
|
- [ ] Naming follows conventions (snake_case, plural tables, singular columns)
|
|
253
|
+
- [ ] Every NEW table has a self-incrementing integer `id` primary key (identity/bigserial)
|
|
254
|
+
- [ ] API-exposed tables also have a UNIQUE `public_id UUID` — and expose that, not the integer id
|
|
217
255
|
- [ ] Every table has id, created_at, updated_at
|
|
218
256
|
- [ ] timestamptz used — not timestamp
|
|
219
257
|
- [ ] All foreign keys indexed
|
|
@@ -10,7 +10,9 @@ Applies when `prisma` or `@prisma/client` is in `package.json` or `schema.prisma
|
|
|
10
10
|
```
|
|
11
11
|
MANDATORY:
|
|
12
12
|
├── One schema.prisma file — NEVER split across multiple files (use comments to section)
|
|
13
|
-
├──
|
|
13
|
+
├── EVERY model has id Int @id @default(autoincrement()) — NEVER a uuid()/cuid() primary key
|
|
14
|
+
├── API-exposed models ALSO get publicId String @unique @default(uuid()) — expose publicId
|
|
15
|
+
│ externally, NEVER the integer id (a sequential id in a URL leaks row counts and is enumerable)
|
|
14
16
|
├── Every model has createdAt DateTime @default(now()) and updatedAt DateTime @updatedAt
|
|
15
17
|
├── Use @map and @@map for snake_case DB column/table names with camelCase Prisma models
|
|
16
18
|
├── Define explicit relation names when a model has multiple relations to the same table
|
|
@@ -21,7 +23,8 @@ MANDATORY:
|
|
|
21
23
|
**GOOD**
|
|
22
24
|
```prisma
|
|
23
25
|
model User {
|
|
24
|
-
id
|
|
26
|
+
id Int @id @default(autoincrement())
|
|
27
|
+
publicId String @unique @default(uuid()) @map("public_id")
|
|
25
28
|
email String @unique
|
|
26
29
|
name String
|
|
27
30
|
role UserRole @default(MEMBER)
|
|
@@ -45,12 +48,16 @@ enum UserRole {
|
|
|
45
48
|
**BAD**
|
|
46
49
|
```prisma
|
|
47
50
|
model User {
|
|
48
|
-
id
|
|
49
|
-
role String
|
|
50
|
-
// Missing createdAt, updatedAt, indexes
|
|
51
|
+
id String @id @default(uuid()) // UUID as PK — scattered index writes, 16-byte FKs
|
|
52
|
+
role String // Should be enum
|
|
53
|
+
// Missing publicId, createdAt, updatedAt, indexes
|
|
51
54
|
}
|
|
52
55
|
```
|
|
53
56
|
|
|
57
|
+
**Scope** — governs NEW models. Existing models with uuid()/cuid() primary keys are NOT retrofitted:
|
|
58
|
+
their relations already point at the string id, so switching the PK is a data migration and a
|
|
59
|
+
Destructive Action Guard item requiring explicit user approval.
|
|
60
|
+
|
|
54
61
|
---
|
|
55
62
|
|
|
56
63
|
## 2. Client Setup
|
|
@@ -144,6 +144,43 @@ MANDATORY:
|
|
|
144
144
|
|
|
145
145
|
---
|
|
146
146
|
|
|
147
|
+
## 6.5. Schema Design — Primary Keys
|
|
148
|
+
|
|
149
|
+
Supabase is PostgreSQL — the full PostgreSQL standards apply. The primary-key rule:
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
MANDATORY:
|
|
153
|
+
├── EVERY new table has a self-incrementing integer primary key named id
|
|
154
|
+
│ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY
|
|
155
|
+
├── API-exposed tables ALSO get public_id UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE
|
|
156
|
+
│ PostgREST exposes every table over HTTP by default — treat tables as API-exposed
|
|
157
|
+
│ unless RLS blocks all client access. Return public_id to clients, never the integer id.
|
|
158
|
+
├── A user_id column referencing auth.users STAYS UUID — auth.uid() returns a UUID and
|
|
159
|
+
│ RLS policies compare against it directly. This is NOT the table's primary key.
|
|
160
|
+
└── Existing tables are NOT retrofitted (see PostgreSQL standards §2 Scope)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
**GOOD**
|
|
164
|
+
```sql
|
|
165
|
+
CREATE TABLE user_profiles (
|
|
166
|
+
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
167
|
+
public_id UUID NOT NULL DEFAULT gen_random_uuid(),
|
|
168
|
+
user_id UUID NOT NULL REFERENCES auth.users (id) ON DELETE CASCADE, -- UUID: RLS compares to auth.uid()
|
|
169
|
+
display_name TEXT NOT NULL,
|
|
170
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
171
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
172
|
+
CONSTRAINT uq_user_profiles_public_id UNIQUE (public_id)
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
ALTER TABLE user_profiles ENABLE ROW LEVEL SECURITY;
|
|
176
|
+
|
|
177
|
+
CREATE POLICY "Users read own profile"
|
|
178
|
+
ON user_profiles FOR SELECT
|
|
179
|
+
USING (auth.uid() = user_id);
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
147
184
|
## 7. Migrations
|
|
148
185
|
|
|
149
186
|
```
|
|
@@ -176,6 +213,9 @@ NEVER:
|
|
|
176
213
|
|
|
177
214
|
## Supabase Verification Checklist
|
|
178
215
|
|
|
216
|
+
- [ ] Every NEW table has a self-incrementing integer `id` primary key
|
|
217
|
+
- [ ] API-exposed tables have a UNIQUE `public_id UUID` — clients get public_id, not the integer id
|
|
218
|
+
- [ ] `user_id` columns referencing auth.users are UUID (RLS compares to auth.uid())
|
|
179
219
|
- [ ] RLS enabled on every table with explicit policies
|
|
180
220
|
- [ ] auth.uid() used in policies — no client-sent user IDs trusted
|
|
181
221
|
- [ ] Service role key only used server-side
|
|
@@ -113,6 +113,31 @@ enum Direction { Up = 'UP', Down = 'DOWN', Left = 'LEFT', Right = 'RIGHT' }
|
|
|
113
113
|
if (status === 'pndng') { /* ... */ }
|
|
114
114
|
```
|
|
115
115
|
|
|
116
|
+
**MANDATORY — a fixed set of values lives in ONE shared declaration, and every comparison
|
|
117
|
+
references it.** Declaring the type is not enough: if the literal `'invoiced'` is typed in two
|
|
118
|
+
files, nothing connects them, and a casing or spelling mismatch fails silently at runtime.
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
// BAD — the same value written as a loose literal in two places.
|
|
122
|
+
// TILE_FILTERS maps the tile to 'invoiced'; this file compares against 'Invoiced'.
|
|
123
|
+
// Different strings. Never matches. No error, no warning — the button just never appears.
|
|
124
|
+
onAddOrder={view === 'orders' && filter !== 'Invoiced' ? handleAdd : undefined}
|
|
125
|
+
|
|
126
|
+
// GOOD — one source of truth, referenced everywhere.
|
|
127
|
+
export const ORDER_FILTER = {
|
|
128
|
+
ORDERS: 'orders',
|
|
129
|
+
INVOICED: 'invoiced',
|
|
130
|
+
} as const;
|
|
131
|
+
export type OrderFilter = typeof ORDER_FILTER[keyof typeof ORDER_FILTER];
|
|
132
|
+
|
|
133
|
+
onAddOrder={view === 'orders' && filter !== ORDER_FILTER.INVOICED ? handleAdd : undefined}
|
|
134
|
+
// A typo is now a compile error, not a silent mismatch a user finds later.
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
This is the structural fix for the casing-mismatch bug class. Case-insensitive comparison
|
|
138
|
+
(`_comparison.md`) is the safety net that catches the same bug where a shared constant doesn't
|
|
139
|
+
exist or the value crosses a system boundary — use both.
|
|
140
|
+
|
|
116
141
|
---
|
|
117
142
|
|
|
118
143
|
## 7. Import Ordering
|