[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill:01M6DYA4302E3GPBTJFHKYWMGG":3,"skill-same:01M6DYA4302E3GPBTJFHKYWMGG":69},{"skillNo":4,"name":5,"nameZh":6,"description":7,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":16,"categories":17,"keywords":23,"publishedAt":42,"linkOnly":43,"markdownContent":44,"markdownBody":45,"frontMatter":46,"refName":47,"commitSha":48,"skillDirectory":49,"forksCount":50,"repositoryHtmlUrl":51,"repositoryHomepageUrl":6,"ownerHtmlUrl":52,"ownerBlogUrl":53,"ownerDescription":6,"updatedAt":54,"install":55,"resources":68},"01M6DYA4302E3GPBTJFHKYWMGG","database",null,"Database rules for Forge client apps on Postgres. Use before adding or changing a table, column, index, constraint, migration, extension, list query, report or job table, in Prisma or SQLAlchemy.","knacklabs\u002Fsymphony-forge","knacklabs","https:\u002F\u002Favatars.githubusercontent.com\u002Fu\u002F304408816?v=4","01M6DY9T9G3AQ6DM846DVTBD2Y","KnackLabs","01M6DY9TDZY6RJMQD6ZM773ZD3",10,"MIT","https:\u002F\u002Fgithub.com\u002Fknacklabs\u002Fsymphony-forge\u002Fblob\u002Fmain\u002F.claude\u002Fskills\u002Fdatabase\u002FSKILL.md",[18],{"categoryNo":19,"categoryName":20,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000010","后端与接口","SCENARIO","使用场景",[5,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41],"constraint","report","for","sqlalchemy","client","apps","extension","column","prisma","query","postgres","migration","use","list","Rules","table","forge","index","2026-11-10 10:12:30",false,"---\nname: database\ndescription: \"Database rules for Forge client apps on Postgres. Use before adding or changing a table, column, index, constraint, migration, extension, list query, report or job table, in Prisma or SQLAlchemy.\"\n---\n\n# Database\n\nThese rules apply to the tables a change adds, with their columns, constraints, indexes and triggers, and the extensions it turns on.\nA change to an existing table follows that table's own style. Never migrate existing tables to these rules unless a story asks.\nWhere the app's constitution differs, it wins, including an existing rule to store money in cents.\n\n## Columns by kind of data\n\n| Kind of data | Postgres | Prisma | SQLAlchemy | Rule |\n|---|---|---|---|---|\n| text | `text` | `String` | `Text` | `CONSTRAINT \"{table}_{column}_length\" CHECK (char_length(\"{column}\") BETWEEN 1 AND 200)` |\n| long-text | `text` | `String` | `Text` | `CONSTRAINT \"{table}_{column}_length\" CHECK (char_length(\"{column}\") \u003C= 10000)` |\n| number | `numeric(19,4)` | `Decimal @db.Decimal(19, 4)` | `Numeric(19, 4)` | No sign check; a plan adds a check for a field when the client needs one. |\n| money | `numeric(19,4)`; currency `text` | `Decimal @db.Decimal(19, 4)`; currency `String` | `Numeric(19, 4)`; currency `Text` | `CONSTRAINT \"{table}_{currency}_code\" CHECK (\"{currency}\" ~ '^[A-Z]{3}$')` |\n| date | `date` | `DateTime @db.Date` | `Date` | A calendar date, with no time zone. |\n| yes-no | `boolean DEFAULT false` | `Boolean @default(false)` | `Boolean, server_default=false()` | Always NOT NULL, even when the field is marked optional. |\n| email | `text` | `String` | `Text` | `CONSTRAINT \"{table}_{column}_format\" CHECK (char_length(\"{column}\") \u003C= 254 AND \"{column}\" ~ '^[^@[:space:]]+@[^@[:space:]]+$')` |\n| status | `text DEFAULT '{first}'` | `String @default(\"{first}\")` | `Text, server_default=\"{first}\"` | `CONSTRAINT \"{table}_{column}_allowed\" CHECK (\"{column}\" IN ({options}))` |\n| link | `uuid` | `String @db.Uuid` | `Uuid` | Follow the module-link rule below; index it. |\n| id | `uuid DEFAULT uuidv7()` | `String @id @default(dbgenerated(\"uuidv7()\")) @db.Uuid` | `mapped_column(Uuid, primary_key=True, server_default=text(\"uuidv7()\"))` | Python code that needs the id first uses `uuid.uuid7()`. |\n| count | `integer` | `Int` | `Integer` | Use `bigint` \u002F `BigInt` \u002F `BigInteger` when it can pass two billion. |\n| instant | `timestamptz` | `DateTime @db.Timestamptz(3)` | `DateTime(timezone=True)` | Store UTC; local time belongs at the edge. |\n| blob read whole | `jsonb` | `Json @db.JsonB` | `JSONB` | Named `CHECK (jsonb_typeof(\"{column}\") = 'object')`. |\n| time range | `tstzrange` | `Unsupported(\"tstzrange\")` | `TSTZRANGE` | Use `'[)'` bounds. |\n| location | `numeric(9,6)` latitude and longitude | `Decimal @db.Decimal(9, 6)` | `Numeric(9, 6)` | Display and rough nearby only; use PostGIS `geography` for radius and shapes. |\n| embedding | `halfvec` | `Unsupported(\"halfvec\")` | pgvector `HALFVEC` | See Extensions; do not add a package unless the story names it. |\n\nOn Postgres 17 or older, where `uuidv7()` does not exist, the app makes the uuidv7 id: Prisma `@default(uuid(7))`, Python `default=uuid7`, and no database default. Use a UUIDv7 implementation the app already has; a new dependency needs a story.\nUse `text`, never `varchar(n)`, with a named length CHECK. Never use `float` or `double precision` for business values.\nStatus is `text` with a named CHECK, never a Postgres enum. Money and decimals travel as decimal strings in JSON.\nEach money field has its own currency column (`totalCurrency` \u002F `total_currency`). Fill it from the app's default currency (INR unless the owner chooses another at setup); show the code in front of the one amount input, with no currency picker. Format with the shared money helper.\nThe email kind is a contact field and is not unique; sign-in emails follow Keys and constraints.\n\n## Keys and constraints\n\n| Concern | Rule |\n|---|---|\n| Module schemas | Each module has its own Postgres schema; core tables live in the `core` schema. |\n| Module links | A link between two business modules stores the id with an index and no foreign key. Links within a module and links to core tables keep their foreign key. |\n\nUse NOT NULL unless the field is really optional. Every constraint has a name: `\u003Ctable>_\u003Ccolumn>_\u003Crule>`.\nWhere a link has a foreign key, use `ON DELETE RESTRICT`. Use `CASCADE` only for detail rows the parent owns, such as order lines; `SET NULL` only for an optional reference. Index every foreign key.\nStore a sign-in email in lower case, with `\u003Ctable>_email_lower CHECK (email = lower(email))`, and a unique key (organisation column first when the table has one). This makes it unique on `lower(email)`; do not use citext.\nThe shared `set_updated_at()` BEFORE UPDATE trigger sets the last-changed time, including raw SQL updates. Use `updatedAtUtc` in Prisma and `updated_at_utc` in Python.\nEvery tenant table has the organisation column. One data layer scopes each query to the caller's organisation, with one refusal test per endpoint. Row-level security is not a default; a regulated client gets it as its own story.\n\n## Migrations\n\nExpand, then contract. Add a new table or column before code uses it. Keep the old table or column for two releases, and remove it only when no live release uses it. Mainline rollback restores the previous release's code and keeps the database, so every migration must work with that code still running.\n\nClose checks only migrations added by the change. It skips this check in a prototype repo with no live release. A new migration that drops or renames a table or column, or changes a column's type, needs a comment that explains why the live release no longer reads it: `-- Contract: the live release no longer reads \"Project\".\"legacyName\".` in Prisma SQL or `# Contract: the live release no longer reads project.legacy_name.` in Alembic Python. Close allows the migration when this line is present; review reads the explanation.\n\n## Indexes for lists\n\nUse `(organisation_id, \u003Cfilter columns>, \u003Csort column> DESC, id DESC) WHERE deleted_at IS NULL`.\nThe default list sort is last change, newest first. In the statements below, `{org}`, `{updated}` and `{deleted}` mean the organisation, last-changed and soft-delete columns in the app's naming style. `{table}`, `{column}`, `{currency}`, `{first}` and `{options}` come from the field-kind table.\n\n| Use | SQL |\n|---|---|\n| Every table's list | `CREATE INDEX \"idx_{table}_list\" ON \"{table}\" (\"{org}\", \"{updated}\" DESC, \"id\" DESC) WHERE \"{deleted}\" IS NULL;` |\n| yes-no, status or link filter | `CREATE INDEX \"idx_{table}_{column}\" ON \"{table}\" (\"{org}\", \"{column}\", \"{updated}\" DESC, \"id\" DESC) WHERE \"{deleted}\" IS NULL;` |\n| text or email search | `CREATE INDEX \"idx_{table}_{column}_search\" ON \"{table}\" USING gin (\"{org}\", \"{column}\" gin_trgm_ops) WHERE \"{deleted}\" IS NULL;` |\n| Every link, filter or not | `CREATE INDEX \"idx_{table}_{column}_fk\" ON \"{table}\" (\"{column}\");` |\n| Last-changed trigger | `CREATE TRIGGER \"{table}_set_updated_at\" BEFORE UPDATE ON \"{table}\" FOR EACH ROW EXECUTE FUNCTION set_updated_at();` |\n\nThe search box uses `ILIKE '%' || $term || '%'`, with a pg_trgm GIN index and the organisation column in front through btree_gin. Bind the term as a parameter.\nWord search in long text uses a generated `tsvector` column (`STORED`), a GIN index and `websearch_to_tsquery`.\nOther field kinds have no automatic filter or search index. Add no index that no query uses. Check a slow query with `EXPLAIN (ANALYZE, BUFFERS)`.\n\n## List paging\n\nUse keyset paging by default: `WHERE (sort, id) \u003C ($lastSort, $lastId) ORDER BY sort DESC, id DESC LIMIT \u003Cn + 1>`.\nShow next and previous links and an approximate total: cap the count at 10,000 and show \"More than 10,000\" above the cap. Reverse the comparison and order for the previous page, then reverse the returned rows.\nUse numbered pages only on small admin screens. Existing lists keep their paging.\n\n## Dashboards and reports\n\nStart with a live query on indexed columns. When pg_stat_statements shows it over about 200 ms, use a materialized view with a unique index, refreshed `CONCURRENTLY` by pg_cron or a job, or a summary table that app code updates in the same transaction. No pg_ivm.\n\n## Change history\n\nApp code writes the history row in the same transaction (in the starters, `HistoryService.record`); no audit triggers.\nPartition history by month only past about 50 to 100 million rows or when old months must be dropped.\n\n## Job tables\n\nWhen a story first adds background jobs or schedules, add a worker entry in `services.toml`\nand a jobs table read with `SELECT ... FOR UPDATE SKIP LOCKED`. Keep the attempt count and\nlast error on the job row. A schedule only inserts job rows; the worker does the work through\nthe owning module's public service. Make each job safe to run again after a worker stops.\nThe first job is clean-up of old sessions, reset tokens and invites.\nAdd these only with the story that needs jobs, not to the starters. No separate queue server.\n\n## jsonb\n\nUse real columns for anything filtered, joined, sorted or constrained. Use jsonb for a blob read whole, with `CHECK (jsonb_typeof(...) = 'object')`.\nWhen an existing blob must be filtered by containment, use a `jsonb_path_ops` GIN index; for one hot key, use a generated column. No hstore.\n\n## Extensions\n\n| Need | Use | Best practice |\n|---|---|---|\n| Similar text and typos | pg_trgm GIN | btree_gin puts the organisation column in front. |\n| Ranked word search | Built-in `tsvector` | Generated STORED column, GIN and `websearch_to_tsquery`. |\n| Accents | unaccent | An `IMMUTABLE` wrapper function lets an index use it. |\n| Trees and graphs | Adjacency list and recursive CTE with `CYCLE` | Start here for org charts and permissions; ltree with GiST for read-heavy trees; a closure table when items move often. |\n| Location | PostGIS `geography` | GiST index and `ST_DWithin`. |\n| Bookings that must not overlap | `tstzrange` | `EXCLUDE USING gist` through btree_gist, or Postgres 18 `WITHOUT OVERLAPS`. |\n| Embeddings | pgvector in a separate table | Store organisation_id, source_type, source_id, model, model_version, content_hash and embedding `halfvec`. Exact scan with the organisation filter under about 100,000 vectors per tenant; then HNSW with m 16, ef_construction 64 and `hnsw.iterative_scan = relaxed_order`. Match the operator class to the distance; re-embed by the model column. A separate vector database only well past 10 million vectors. |\n| Large tables | Partitioning with pg_partman | Only past about 50 to 100 million rows or for retention drops; RANGE by time or HASH by organisation. |\n| Scheduled SQL | pg_cron | SQL schedules only. |\n| Time series | Partitioned table | BRIN index. |\n| Slow queries | pg_stat_statements, on from day one | auto_explain where the host offers it: `log_min_duration` about 500 ms, `log_analyze` on, `log_timing` off, sample rate about 0.1. |\n\nAllowed list (AWS RDS, Google Cloud SQL, Azure and Neon): pgvector, ltree, pg_trgm, unaccent, fuzzystrmatch, citext, PostGIS, btree_gist, btree_gin, pg_partman, pg_cron, pg_stat_statements, pgcrypto.\nEvery other extension (for example AGE, TimescaleDB, pgmq or pg_search) needs the owner's OK for this client. Ask first, then write this line under Architecture choices in `docs\u002Fproduct\u002FCONSTITUTION.md`:\n\n`- Postgres extension \u003Cname>: the owner allows it on \u003Cdate> (\u003Cwhy>)`\n\nAvoid pg_ivm. pg_uuidv7 is not needed: Postgres 18 has `uuidv7()`.\nTurn an extension on in its own migration in the first story that needs it: `CREATE EXTENSION IF NOT EXISTS \u003Cname>;`. New starters have pg_stat_statements, pg_trgm and btree_gin on from day one; existing apps keep their extensions.\nIf a deploy stops because the app's user cannot turn an extension on, turn it on once as the host's admin user. In a Prisma app run `prisma migrate resolve --rolled-back \u003Cmigration>` (the starter's extension migration is `20261026000000_extensions`); Alembic needs no step. Then deploy again. Do not hide the database error.\n\n## Speed without a cache\n\nNo cache server at the start. Add one only for a slow page that pg_stat_statements measures and indexes cannot fix, for sessions or rate limits across instances, or for a hot aggregate read again and again. The story must name the new moving part.\n\n## Prisma and SQLAlchemy\n\n| Thing | Prisma | SQLAlchemy and Alembic |\n|---|---|---|\n| uuidv7 default | `@default(dbgenerated(\"uuidv7()\")) @db.Uuid` | `server_default=text(\"uuidv7()\")` with `Uuid` |\n| timestamptz | `DateTime @db.Timestamptz(3)` | `DateTime(timezone=True)` |\n| CHECK | Raw SQL: `prisma migrate dev --create-only`, then edit the SQL | `CheckConstraint(..., name=...)` in the model and `op.create_check_constraint`; autogenerate misses CHECK changes. |\n| Partial index | `@@index([...], where: ...)` with the `partialIndexes` preview feature on | `Index(..., postgresql_where=...)` |\n| Trigram GIN | `@@index([...], type: Gin)` with `ops: raw(\"gin_trgm_ops\")` | `postgresql_using=\"gin\"`, `postgresql_ops` |\n| Generated tsvector | `Unsupported(\"tsvector\")` plus raw SQL | `Computed(..., persisted=True)` with `TSVECTOR` |\n| Trigger, function, extension, exclusion constraint | Raw SQL in the migration | Raw SQL; `ExcludeConstraint` for an exclusion constraint. |\n| Drift check after raw SQL | After the shadow database setup below, `prisma migrate diff --from-migrations apps\u002Fapi\u002Fprisma\u002Fmigrations --to-schema apps\u002Fapi\u002Fprisma\u002Fschema --script` must print an empty migration | `alembic check` must pass. |\n\nBefore running the Prisma drift check against a migrations directory:\n\n1. Create a separate, empty development database, for example `CREATE DATABASE app_shadow;`, using a database admin if needed. Give the migration user ownership of this database.\n2. Set `SHADOW_DATABASE_URL` in the command's environment to that database's connection URL. Keep credentials out of git.\n3. In `prisma.config.ts`, import `env` from `prisma\u002Fconfig` and add `shadowDatabaseUrl: env(\"SHADOW_DATABASE_URL\")` to the existing `datasource` object, beside its `url`. Keep the existing schema and migrations paths.\n4. Run the drift check from the app root with both `DATABASE_URL` and `SHADOW_DATABASE_URL` set.\n\nThe shadow database must be disposable and different from every database holding app data. Prisma resets it while replaying migrations; never point `shadowDatabaseUrl` at the development or production app database. This setup is for development checks, not production deployment. See [Prisma's shadow database setup](https:\u002F\u002Fwww.prisma.io\u002Fdocs\u002Form\u002Fv7\u002Fprisma-migrate\u002Funderstanding-prisma-migrate\u002Fshadow-database).\n\nState each Prisma index in full, including partial WHERE, type and ops. CHECK constraints and triggers live only in migration SQL: Prisma neither states nor compares them. Never edit a merged migration.\n","# Database\n\nThese rules apply to the tables a change adds, with their columns, constraints, indexes and triggers, and the extensions it turns on.\nA change to an existing table follows that table's own style. Never migrate existing tables to these rules unless a story asks.\nWhere the app's constitution differs, it wins, including an existing rule to store money in cents.\n\n## Columns by kind of data\n\n| Kind of data | Postgres | Prisma | SQLAlchemy | Rule |\n|---|---|---|---|---|\n| text | `text` | `String` | `Text` | `CONSTRAINT \"{table}_{column}_length\" CHECK (char_length(\"{column}\") BETWEEN 1 AND 200)` |\n| long-text | `text` | `String` | `Text` | `CONSTRAINT \"{table}_{column}_length\" CHECK (char_length(\"{column}\") \u003C= 10000)` |\n| number | `numeric(19,4)` | `Decimal @db.Decimal(19, 4)` | `Numeric(19, 4)` | No sign check; a plan adds a check for a field when the client needs one. |\n| money | `numeric(19,4)`; currency `text` | `Decimal @db.Decimal(19, 4)`; currency `String` | `Numeric(19, 4)`; currency `Text` | `CONSTRAINT \"{table}_{currency}_code\" CHECK (\"{currency}\" ~ '^[A-Z]{3}$')` |\n| date | `date` | `DateTime @db.Date` | `Date` | A calendar date, with no time zone. |\n| yes-no | `boolean DEFAULT false` | `Boolean @default(false)` | `Boolean, server_default=false()` | Always NOT NULL, even when the field is marked optional. |\n| email | `text` | `String` | `Text` | `CONSTRAINT \"{table}_{column}_format\" CHECK (char_length(\"{column}\") \u003C= 254 AND \"{column}\" ~ '^[^@[:space:]]+@[^@[:space:]]+$')` |\n| status | `text DEFAULT '{first}'` | `String @default(\"{first}\")` | `Text, server_default=\"{first}\"` | `CONSTRAINT \"{table}_{column}_allowed\" CHECK (\"{column}\" IN ({options}))` |\n| link | `uuid` | `String @db.Uuid` | `Uuid` | Follow the module-link rule below; index it. |\n| id | `uuid DEFAULT uuidv7()` | `String @id @default(dbgenerated(\"uuidv7()\")) @db.Uuid` | `mapped_column(Uuid, primary_key=True, server_default=text(\"uuidv7()\"))` | Python code that needs the id first uses `uuid.uuid7()`. |\n| count | `integer` | `Int` | `Integer` | Use `bigint` \u002F `BigInt` \u002F `BigInteger` when it can pass two billion. |\n| instant | `timestamptz` | `DateTime @db.Timestamptz(3)` | `DateTime(timezone=True)` | Store UTC; local time belongs at the edge. |\n| blob read whole | `jsonb` | `Json @db.JsonB` | `JSONB` | Named `CHECK (jsonb_typeof(\"{column}\") = 'object')`. |\n| time range | `tstzrange` | `Unsupported(\"tstzrange\")` | `TSTZRANGE` | Use `'[)'` bounds. |\n| location | `numeric(9,6)` latitude and longitude | `Decimal @db.Decimal(9, 6)` | `Numeric(9, 6)` | Display and rough nearby only; use PostGIS `geography` for radius and shapes. |\n| embedding | `halfvec` | `Unsupported(\"halfvec\")` | pgvector `HALFVEC` | See Extensions; do not add a package unless the story names it. |\n\nOn Postgres 17 or older, where `uuidv7()` does not exist, the app makes the uuidv7 id: Prisma `@default(uuid(7))`, Python `default=uuid7`, and no database default. Use a UUIDv7 implementation the app already has; a new dependency needs a story.\nUse `text`, never `varchar(n)`, with a named length CHECK. Never use `float` or `double precision` for business values.\nStatus is `text` with a named CHECK, never a Postgres enum. Money and decimals travel as decimal strings in JSON.\nEach money field has its own currency column (`totalCurrency` \u002F `total_currency`). Fill it from the app's default currency (INR unless the owner chooses another at setup); show the code in front of the one amount input, with no currency picker. Format with the shared money helper.\nThe email kind is a contact field and is not unique; sign-in emails follow Keys and constraints.\n\n## Keys and constraints\n\n| Concern | Rule |\n|---|---|\n| Module schemas | Each module has its own Postgres schema; core tables live in the `core` schema. |\n| Module links | A link between two business modules stores the id with an index and no foreign key. Links within a module and links to core tables keep their foreign key. |\n\nUse NOT NULL unless the field is really optional. Every constraint has a name: `\u003Ctable>_\u003Ccolumn>_\u003Crule>`.\nWhere a link has a foreign key, use `ON DELETE RESTRICT`. Use `CASCADE` only for detail rows the parent owns, such as order lines; `SET NULL` only for an optional reference. Index every foreign key.\nStore a sign-in email in lower case, with `\u003Ctable>_email_lower CHECK (email = lower(email))`, and a unique key (organisation column first when the table has one). This makes it unique on `lower(email)`; do not use citext.\nThe shared `set_updated_at()` BEFORE UPDATE trigger sets the last-changed time, including raw SQL updates. Use `updatedAtUtc` in Prisma and `updated_at_utc` in Python.\nEvery tenant table has the organisation column. One data layer scopes each query to the caller's organisation, with one refusal test per endpoint. Row-level security is not a default; a regulated client gets it as its own story.\n\n## Migrations\n\nExpand, then contract. Add a new table or column before code uses it. Keep the old table or column for two releases, and remove it only when no live release uses it. Mainline rollback restores the previous release's code and keeps the database, so every migration must work with that code still running.\n\nClose checks only migrations added by the change. It skips this check in a prototype repo with no live release. A new migration that drops or renames a table or column, or changes a column's type, needs a comment that explains why the live release no longer reads it: `-- Contract: the live release no longer reads \"Project\".\"legacyName\".` in Prisma SQL or `# Contract: the live release no longer reads project.legacy_name.` in Alembic Python. Close allows the migration when this line is present; review reads the explanation.\n\n## Indexes for lists\n\nUse `(organisation_id, \u003Cfilter columns>, \u003Csort column> DESC, id DESC) WHERE deleted_at IS NULL`.\nThe default list sort is last change, newest first. In the statements below, `{org}`, `{updated}` and `{deleted}` mean the organisation, last-changed and soft-delete columns in the app's naming style. `{table}`, `{column}`, `{currency}`, `{first}` and `{options}` come from the field-kind table.\n\n| Use | SQL |\n|---|---|\n| Every table's list | `CREATE INDEX \"idx_{table}_list\" ON \"{table}\" (\"{org}\", \"{updated}\" DESC, \"id\" DESC) WHERE \"{deleted}\" IS NULL;` |\n| yes-no, status or link filter | `CREATE INDEX \"idx_{table}_{column}\" ON \"{table}\" (\"{org}\", \"{column}\", \"{updated}\" DESC, \"id\" DESC) WHERE \"{deleted}\" IS NULL;` |\n| text or email search | `CREATE INDEX \"idx_{table}_{column}_search\" ON \"{table}\" USING gin (\"{org}\", \"{column}\" gin_trgm_ops) WHERE \"{deleted}\" IS NULL;` |\n| Every link, filter or not | `CREATE INDEX \"idx_{table}_{column}_fk\" ON \"{table}\" (\"{column}\");` |\n| Last-changed trigger | `CREATE TRIGGER \"{table}_set_updated_at\" BEFORE UPDATE ON \"{table}\" FOR EACH ROW EXECUTE FUNCTION set_updated_at();` |\n\nThe search box uses `ILIKE '%' || $term || '%'`, with a pg_trgm GIN index and the organisation column in front through btree_gin. Bind the term as a parameter.\nWord search in long text uses a generated `tsvector` column (`STORED`), a GIN index and `websearch_to_tsquery`.\nOther field kinds have no automatic filter or search index. Add no index that no query uses. Check a slow query with `EXPLAIN (ANALYZE, BUFFERS)`.\n\n## List paging\n\nUse keyset paging by default: `WHERE (sort, id) \u003C ($lastSort, $lastId) ORDER BY sort DESC, id DESC LIMIT \u003Cn + 1>`.\nShow next and previous links and an approximate total: cap the count at 10,000 and show \"More than 10,000\" above the cap. Reverse the comparison and order for the previous page, then reverse the returned rows.\nUse numbered pages only on small admin screens. Existing lists keep their paging.\n\n## Dashboards and reports\n\nStart with a live query on indexed columns. When pg_stat_statements shows it over about 200 ms, use a materialized view with a unique index, refreshed `CONCURRENTLY` by pg_cron or a job, or a summary table that app code updates in the same transaction. No pg_ivm.\n\n## Change history\n\nApp code writes the history row in the same transaction (in the starters, `HistoryService.record`); no audit triggers.\nPartition history by month only past about 50 to 100 million rows or when old months must be dropped.\n\n## Job tables\n\nWhen a story first adds background jobs or schedules, add a worker entry in `services.toml`\nand a jobs table read with `SELECT ... FOR UPDATE SKIP LOCKED`. Keep the attempt count and\nlast error on the job row. A schedule only inserts job rows; the worker does the work through\nthe owning module's public service. Make each job safe to run again after a worker stops.\nThe first job is clean-up of old sessions, reset tokens and invites.\nAdd these only with the story that needs jobs, not to the starters. No separate queue server.\n\n## jsonb\n\nUse real columns for anything filtered, joined, sorted or constrained. Use jsonb for a blob read whole, with `CHECK (jsonb_typeof(...) = 'object')`.\nWhen an existing blob must be filtered by containment, use a `jsonb_path_ops` GIN index; for one hot key, use a generated column. No hstore.\n\n## Extensions\n\n| Need | Use | Best practice |\n|---|---|---|\n| Similar text and typos | pg_trgm GIN | btree_gin puts the organisation column in front. |\n| Ranked word search | Built-in `tsvector` | Generated STORED column, GIN and `websearch_to_tsquery`. |\n| Accents | unaccent | An `IMMUTABLE` wrapper function lets an index use it. |\n| Trees and graphs | Adjacency list and recursive CTE with `CYCLE` | Start here for org charts and permissions; ltree with GiST for read-heavy trees; a closure table when items move often. |\n| Location | PostGIS `geography` | GiST index and `ST_DWithin`. |\n| Bookings that must not overlap | `tstzrange` | `EXCLUDE USING gist` through btree_gist, or Postgres 18 `WITHOUT OVERLAPS`. |\n| Embeddings | pgvector in a separate table | Store organisation_id, source_type, source_id, model, model_version, content_hash and embedding `halfvec`. Exact scan with the organisation filter under about 100,000 vectors per tenant; then HNSW with m 16, ef_construction 64 and `hnsw.iterative_scan = relaxed_order`. Match the operator class to the distance; re-embed by the model column. A separate vector database only well past 10 million vectors. |\n| Large tables | Partitioning with pg_partman | Only past about 50 to 100 million rows or for retention drops; RANGE by time or HASH by organisation. |\n| Scheduled SQL | pg_cron | SQL schedules only. |\n| Time series | Partitioned table | BRIN index. |\n| Slow queries | pg_stat_statements, on from day one | auto_explain where the host offers it: `log_min_duration` about 500 ms, `log_analyze` on, `log_timing` off, sample rate about 0.1. |\n\nAllowed list (AWS RDS, Google Cloud SQL, Azure and Neon): pgvector, ltree, pg_trgm, unaccent, fuzzystrmatch, citext, PostGIS, btree_gist, btree_gin, pg_partman, pg_cron, pg_stat_statements, pgcrypto.\nEvery other extension (for example AGE, TimescaleDB, pgmq or pg_search) needs the owner's OK for this client. Ask first, then write this line under Architecture choices in `docs\u002Fproduct\u002FCONSTITUTION.md`:\n\n`- Postgres extension \u003Cname>: the owner allows it on \u003Cdate> (\u003Cwhy>)`\n\nAvoid pg_ivm. pg_uuidv7 is not needed: Postgres 18 has `uuidv7()`.\nTurn an extension on in its own migration in the first story that needs it: `CREATE EXTENSION IF NOT EXISTS \u003Cname>;`. New starters have pg_stat_statements, pg_trgm and btree_gin on from day one; existing apps keep their extensions.\nIf a deploy stops because the app's user cannot turn an extension on, turn it on once as the host's admin user. In a Prisma app run `prisma migrate resolve --rolled-back \u003Cmigration>` (the starter's extension migration is `20261026000000_extensions`); Alembic needs no step. Then deploy again. Do not hide the database error.\n\n## Speed without a cache\n\nNo cache server at the start. Add one only for a slow page that pg_stat_statements measures and indexes cannot fix, for sessions or rate limits across instances, or for a hot aggregate read again and again. The story must name the new moving part.\n\n## Prisma and SQLAlchemy\n\n| Thing | Prisma | SQLAlchemy and Alembic |\n|---|---|---|\n| uuidv7 default | `@default(dbgenerated(\"uuidv7()\")) @db.Uuid` | `server_default=text(\"uuidv7()\")` with `Uuid` |\n| timestamptz | `DateTime @db.Timestamptz(3)` | `DateTime(timezone=True)` |\n| CHECK | Raw SQL: `prisma migrate dev --create-only`, then edit the SQL | `CheckConstraint(..., name=...)` in the model and `op.create_check_constraint`; autogenerate misses CHECK changes. |\n| Partial index | `@@index([...], where: ...)` with the `partialIndexes` preview feature on | `Index(..., postgresql_where=...)` |\n| Trigram GIN | `@@index([...], type: Gin)` with `ops: raw(\"gin_trgm_ops\")` | `postgresql_using=\"gin\"`, `postgresql_ops` |\n| Generated tsvector | `Unsupported(\"tsvector\")` plus raw SQL | `Computed(..., persisted=True)` with `TSVECTOR` |\n| Trigger, function, extension, exclusion constraint | Raw SQL in the migration | Raw SQL; `ExcludeConstraint` for an exclusion constraint. |\n| Drift check after raw SQL | After the shadow database setup below, `prisma migrate diff --from-migrations apps\u002Fapi\u002Fprisma\u002Fmigrations --to-schema apps\u002Fapi\u002Fprisma\u002Fschema --script` must print an empty migration | `alembic check` must pass. |\n\nBefore running the Prisma drift check against a migrations directory:\n\n1. Create a separate, empty development database, for example `CREATE DATABASE app_shadow;`, using a database admin if needed. Give the migration user ownership of this database.\n2. Set `SHADOW_DATABASE_URL` in the command's environment to that database's connection URL. Keep credentials out of git.\n3. In `prisma.config.ts`, import `env` from `prisma\u002Fconfig` and add `shadowDatabaseUrl: env(\"SHADOW_DATABASE_URL\")` to the existing `datasource` object, beside its `url`. Keep the existing schema and migrations paths.\n4. Run the drift check from the app root with both `DATABASE_URL` and `SHADOW_DATABASE_URL` set.\n\nThe shadow database must be disposable and different from every database holding app data. Prisma resets it while replaying migrations; never point `shadowDatabaseUrl` at the development or production app database. This setup is for development checks, not production deployment. See [Prisma's shadow database setup](https:\u002F\u002Fwww.prisma.io\u002Fdocs\u002Form\u002Fv7\u002Fprisma-migrate\u002Funderstanding-prisma-migrate\u002Fshadow-database).\n\nState each Prisma index in full, including partial WHERE, type and ops. CHECK constraints and triggers live only in migration SQL: Prisma neither states nor compares them. Never edit a merged migration.\n",{"name":5,"description":7},"main","7450721ddf8c0cbedf9d84c8311677a59f817675",".claude\u002Fskills\u002Fdatabase",9,"https:\u002F\u002Fgithub.com\u002Fknacklabs\u002Fsymphony-forge","https:\u002F\u002Fgithub.com\u002Fknacklabs","https:\u002F\u002Fknacklabs.ai","2026-11-12 03:29:30",{"source":8,"skillName":5,"commands":56,"prompt":66,"zipAvailable":67},[57,60,63],{"tool":58,"command":59},"npx","npx skills add knacklabs\u002Fsymphony-forge --skill database",{"tool":61,"command":62},"bunx","bunx skills add knacklabs\u002Fsymphony-forge --skill database",{"tool":64,"command":65},"pnpm","pnpm dlx skills add knacklabs\u002Fsymphony-forge --skill database","安装技能，技能地址：https:\u002F\u002Fgithub.com\u002Fknacklabs\u002Fsymphony-forge\u002Fblob\u002Fmain\u002F.claude\u002Fskills\u002Fdatabase\u002FSKILL.md",true,[],[70,102,109,116,144,167],{"skillNo":71,"name":72,"nameZh":6,"description":73,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":74,"sourceHtmlUrl":75,"categories":76,"keywords":80,"publishedAt":42},"01M6DYCB6Y89E5K32Y68B69PYS","simplified-technical-english","Writes and rewrites text in Simplified Technical English (ASD-STE100), a controlled language for clear technical documentation. Use when the user asks for STE, Simplified Technical English, controlled language, or plain technical writing, and when the user asks to write, rewrite, review, or check technical documentation, procedures, manuals, instructions, warnings, or reports.","https:\u002F\u002Fgithub.com\u002Fknacklabs\u002Fsymphony-forge\u002Fblob\u002Fmain\u002Fsrc\u002Fforge\u002Ftemplates\u002Fskills\u002Fsimplified-technical-english\u002FLICENSE","https:\u002F\u002Fgithub.com\u002Fknacklabs\u002Fsymphony-forge\u002Fblob\u002Fmain\u002Fsrc\u002Fforge\u002Ftemplates\u002Fskills\u002Fsimplified-technical-english\u002FSKILL.md",[77],{"categoryNo":78,"categoryName":79,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000018","写作与文案",[81,82,83,84,85,86,87,88,89,90,91,92,93,94,36,95,96,97,98,99,100,26,101],"technical","english","simplified technical english","writing","technical documentation","review","technical writing","documentation","ste","text","check","write","instructions","user","reports","issue","language","clear","rewrite","manuals","when",{"skillNo":103,"name":72,"nameZh":6,"description":73,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":104,"sourceHtmlUrl":105,"categories":106,"keywords":108,"publishedAt":42},"01M6DYB15X4C4A4025B0ZVCP6Q","https:\u002F\u002Fgithub.com\u002Fknacklabs\u002Fsymphony-forge\u002Fblob\u002Fmain\u002F.codex\u002Fskills\u002Fsimplified-technical-english\u002FLICENSE","https:\u002F\u002Fgithub.com\u002Fknacklabs\u002Fsymphony-forge\u002Fblob\u002Fmain\u002F.codex\u002Fskills\u002Fsimplified-technical-english\u002FSKILL.md",[107],{"categoryNo":78,"categoryName":79,"taxonomyType":21,"taxonomyTypeDesc":22},[81,82,83,84,85,86,87,88,89,90,91,92,93,94,36,95,96,97,98,99,100,26,101],{"skillNo":110,"name":72,"nameZh":6,"description":73,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":111,"sourceHtmlUrl":112,"categories":113,"keywords":115,"publishedAt":42},"01M6DYACDT517J69KRMM97BRG7","https:\u002F\u002Fgithub.com\u002Fknacklabs\u002Fsymphony-forge\u002Fblob\u002Fmain\u002F.claude\u002Fskills\u002Fsimplified-technical-english\u002FLICENSE","https:\u002F\u002Fgithub.com\u002Fknacklabs\u002Fsymphony-forge\u002Fblob\u002Fmain\u002F.claude\u002Fskills\u002Fsimplified-technical-english\u002FSKILL.md",[114],{"categoryNo":78,"categoryName":79,"taxonomyType":21,"taxonomyTypeDesc":22},[81,82,83,84,85,86,87,88,89,90,91,92,93,94,36,95,96,97,98,99,100,26,101],{"skillNo":117,"name":40,"nameZh":6,"description":118,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":119,"categories":120,"keywords":121,"publishedAt":42},"01M6DYDAWZVG23SR4B8ETHDQAF","Run Forge, which takes a story from approval to a merged pull request. Use when the human asks what to do next, wants to plan a story, start a task or fix, build, close, save a spec or decision, add to the roadmap, check the setup, or see the board.","https:\u002F\u002Fgithub.com\u002Fknacklabs\u002Fsymphony-forge\u002Fblob\u002Fmain\u002Ftests\u002Ffixtures\u002Fforge-v1.2.2\u002Fsrc\u002Fforge\u002Ftemplates\u002Fskill.md",[],[40,122,123,124,125,126,127,128,129,130,91,131,132,36,133,134,135,136,137,138,139,140,141,142,143,101],"spec","setup","pull request","run","pull","start","save","plan","decision","board","fix","roadmap","Story","build","do","task","next","human","add","close","which","request",{"skillNo":145,"name":146,"nameZh":6,"description":147,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":148,"categories":149,"keywords":153,"publishedAt":42},"01M6DYD7G88G0Q478GTNAY7Y46","test-audit","Use whenever writing, changing, reviewing, or auditing tests. Gates new and changed tests and guides focused audits of low-value, implementation-coupled, or duplicative coverage and the test-only production seams it demands.","https:\u002F\u002Fgithub.com\u002Fknacklabs\u002Fsymphony-forge\u002Fblob\u002Fmain\u002Ftests\u002Ffixtures\u002Fforge-v1.2.2\u002F.codex\u002Fskills\u002Ftest-audit\u002FSKILL.md",[150],{"categoryNo":151,"categoryName":152,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000008","测试与质量",[154,155,156,84,157,158,36,159,160,161,162,163,164,165,166],"test audit","test","audit","Production","coverage","only","implementation","gates","tests","guides","it","value","new",{"skillNo":168,"name":169,"nameZh":6,"description":170,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":171,"categories":172,"keywords":176,"publishedAt":42},"01M6DYD45AQMFJJ7C57078QY6K","app-baseline","The baseline every Forge client app gets without anyone asking. Use for any frontend or UI work in a Forge client repo, including screens, layout, navigation, forms, tables and prototype demos, before building or changing it.","https:\u002F\u002Fgithub.com\u002Fknacklabs\u002Fsymphony-forge\u002Fblob\u002Fmain\u002Ftests\u002Ffixtures\u002Fforge-v1.2.2\u002F.codex\u002Fskills\u002Fapp-baseline\u002FSKILL.md",[173],{"categoryNo":174,"categoryName":175,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000009","前端开发",[177,178,179,180,181,182,40,183,184,185,36,186,187,188,28,189,164,190,26],"baseline","app","app baseline","frontend","prototype","ui","work","repo","before building","navigation","forms","layout","demos","tables"]