Design notes
The source code contains no comments, so the reasons behind non-obvious choices are recorded here. Each note names the file where the choice lives.
Core
injectAuthProvider() throws its own error
libs/core/src/lib/inject-pidrang.ts
AUTH_PROVIDER is an optional token: not every app configures authentication. injectAuthProvider() therefore injects it with { optional: true } and throws a Pidrang-specific message (no auth provider configured, pass authProvider to providePidrang()) instead of letting the caller hit a generic dependency injection error or a silent undefined. Follow the same pattern for any new optional provider.
injectResource(name) throws on an unknown name
libs/core/src/lib/inject-pidrang.ts
A typo in a resource name should fail at the call site, not surface later as a confusing runtime error somewhere else. Lookup helpers in core throw instead of returning undefined.
injectResourceList accepts a reactive resource name
libs/core/src/lib/resource-list.ts
It wraps DataProvider.getList in Angular's resource() so callers get value, status, error and reload as signals without depending on any UI layer. The resource name may be a function (for example a route parameter signal) so one component instance reused across resources still reloads. Returning undefined from the params function skips loading, which is resource()'s own convention.
Field.readOnly, nullable and hasDefault replace name-based exclusion
libs/core/src/lib/field.ts, libs/demo-shared/src/lib/resource-create-state.ts, resource-edit-state.ts
resource-create-state.ts and resource-edit-state.ts used to hardcode NON_EDITABLE_FIELDS = new Set(['id', 'created_at']) to keep server-generated columns out of create/edit forms. That breaks for any table whose generated columns are named differently, and it cannot express "generated but still worth showing" separately from "never show." readOnly now carries that decision as schema metadata, so any Resource (hand-written, like demo-resources.ts, or produced by pidrang init) must set it on every column the database generates or defaults, or that column becomes editable. readOnly stays independent of hidden: hidden is a list/edit display choice (added for #6), readOnly is a write-eligibility choice; a column can be shown on the show page and never writable, or writable but absent from the list. nullable and hasDefault are consumed by ResourceForm (see the UI layers note below), not by the state layer, which only ever sees the final payload.
Supabase
as never casts in create and update
libs/supabase/src/lib/supabase-data-provider.ts
Without a typed Supabase Database schema, postgrest-js cannot unify its expected insert and update row type with the unconstrained generic T of DataProvider. never is the narrowest cast that compiles. Removing it properly means adding real Database typing support, which is a separate change; do not replace it with any or a wider cast.
Every table needs an id primary key
libs/supabase/src/lib/supabase-data-provider.ts
getOne, update and delete filter on id. Composite or differently named keys are not supported. This is an intentional scope limit, not an oversight.
contains uses imatch, not ilike
libs/supabase/src/lib/supabase-data-provider.ts, escape-regex.ts
contains must match every character literally. With ilike, % and _ are wildcards, and PostgREST also turns * into %, so a search for a literal * cannot be written at all. imatch (Postgres ~*, case-insensitive regex) with every regex metacharacter escaped matches the text as typed; the conformance cases for %, _, * and . hold it to that. The cost: like ilike '%…%', a regex search cannot use a btree index, so it scans the table. On a large table, add a trigram index, which serves ~*: create extension if not exists pg_trgm; create index on <table> using gin (<column> gin_trgm_ops);.
UI layers
ResourceForm looks up fields by runtime name
libs/material/src/lib/resource-form/resource-form.ts, libs/tailwind/src/lib/resource-form/resource-form.ts
The field name comes from the runtime Resource.fields schema, not from the keys of T, so it cannot be typed against the form model without losing the dynamic behavior the fields input exists for. fieldTree(name) therefore casts the model to a record of fields.
Material warn color
apps/demo-material/src/theme.scss
The color="warn" support of mat.theme() only covers Material 2 compatible themes. With a Material 3 theme the warn palette has to be declared again, scoped to [color='warn'], for buttons to render red.
timestamp, time, numeric and date stay text, never Date or Number
libs/core/src/lib/field.ts, libs/core/src/lib/field-presentation.ts, libs/core/src/lib/resource-form-payload.ts, libs/material/src/lib/resource-form/resource-form.ts, libs/tailwind/src/lib/resource-form/resource-form.ts
A timestamptz rendered through <input type="date"> silently drops its time of day, and a bigint/numeric column read through JS Number silently loses precision past 2^53 or past what a double can hold exactly. timestamp, time, numeric and date are therefore all bound to a plain <input type="text">, never a native date/number input, so a touched value round-trips byte for byte. date used to keep its native <input type="date">; it was moved into the same text-bound group because that native input let Signal Forms hand back a Date instance instead of a string (see below). Format validation is attached with Signal Forms' pattern(), not hand-written parsing: formatPatternForField() (in core, shared by both UI layers instead of each keeping its own copy) holds one regex per type — tightened to real calendar/clock bounds (hour 00–23, minute/second 00–59, month 01–12, day 01–31), not just digit counts, so 2024-99-99T99:99 is rejected instead of accepted — and fieldError() turns a failed match into a plain-English hint next to the field.
Binding the input to type="text" is not enough by itself: Signal Forms chooses how to parse a native input event by inspecting the field's current value, not the DOM element's type attribute. A numeric field whose loaded value happens to be a JS number (which is exactly what it is: PostgREST serializes numeric/int8 as a bare, unquoted JSON number — see the CLI note below) gets every subsequent keystroke parsed back through Number(), silently reproducing the precision loss the whole numeric type exists to avoid; a date field starting from null similarly came back as a Date object once typed into, and a null text-bound field never even registered as dirty() when typed into (Angular logged NG01921, "text inputs should use empty strings to represent null values"). None of this is documented by Signal Forms' types — it was found by writing a failing test against the exact shape real data arrives in ({ amount: 1 }, not { amount: '1' }), not by reading the .d.ts files. normalizeResourceValue() (resource-form-payload.ts) fixes it at the boundary: building draft from value() coerces every text-bound field's value to a string ('' for null/undefined) before Signal Forms ever sees it, so the field's value type is always string and native parsing never kicks in.
The validators are attached through applyEach(path, (child) => { pattern(child, ...); required(child, ...); }) instead of a loop over this.fields() inside the schema callback. form()'s schema callback runs once, synchronously, while the component is still being constructed, before Angular has bound its required inputs (fields, value) — reading this.fields() there throws NG0950. applyEach attaches one schema entry to every key of the dynamic record without needing to know the keys up front, and both validators' second argument accepts a reactive function instead of a literal value, so this.fields() is only read later, each time Signal Forms actually validates a field, by which point the inputs are bound.
pattern() alone does not catch an emptied field: Signal Forms treats an empty value as vacuously matching any pattern, the same way HTML's own pattern attribute does. A cleared timestamp/time/numeric/date field, if the column does not allow null, used to pass client-side validation and then fail with a raw Postgres error. required() is attached alongside pattern(). Its when is not just fieldRequiresValueWhenEmpty(field) (true for those four types when !field.nullable) — it is !field.hasDefault || ctx.state.dirty(). A hasDefault field left untouched must never be required: it starts at its blank seed value precisely because the client does not know the real default, and on create that untouched state is what tells buildResourcePayload() to omit it and let the server's default apply (see below) — requiring it would block every create on that column. Once the user actually dirties a hasDefault field, though, required() applies again: clearing a field the server would otherwise default is a deliberate edit, not "leave it alone", and an empty value sent at that point would still fail server-side. ctx.state.dirty() is read live inside the validator, so the same rule holds for a field dirtied and then cleared back to empty. When the field genuinely is nullable, buildResourcePayload() converts the text input's '' to null before it reaches the server.
Blocking submit is mode-dependent (shouldBlockSubmit() in core): on create, nothing pre-exists yet, so any invalid field blocks, including one the user never touched (otherwise a required field left at its blank seed value would silently submit). On edit, only a field the user actually touched blocks — an untouched, pre-existing invalid value from legacy data must not block an otherwise unrelated save. A blocked submit attempt calls markAsTouched() (Signal Forms' own FieldState method) on every blocking field, and fieldError() shows its hint on dirty() || touched(), not dirty() alone — otherwise a required field left at its blank seed value blocked Save with no visible explanation anywhere on the page.
Payload trimming (buildResourcePayload()) sends a touched field on create as much as on edit, and leaves an untouched one out, with one create-only exception described at the end of this paragraph. An earlier version of this omitted a field on create only if it had hasDefault, sending every other untouched field at its placeholder seed value (''/0/false from defaultValueForField()). That let an untouched, nullable, no-default text field reach the server as "" instead of NULL on create — a real bug, since nothing about '' says "I was never touched," and the client-side placeholder was never a value the user actually chose. The fix removed the asymmetry instead of special-casing nullable fields further: on create as on edit, an untouched field — whatever its hasDefault/nullable combination — is left out entirely (apart from the create-only boolean/number exception below), and the database decides what an absent column means (its default, NULL, or a NOT NULL violation the server reports). An edit where nothing was actually touched produces an empty payload; onSubmit() does not call save.emit() for mode 'edit' when that happens, since PostgREST's .single() errors on an empty PATCH body. Create keeps emitting an empty payload, though — "create a row from nothing but defaults" is still a legitimate action with no pre-existing row to fail against. Sending only touched fields is not just cosmetic: a numeric/bigint value already arrives from the server JSON-parsed, so by the time it reaches the form it may already have lost precision past Number's safe range (see the normalization note above); re-sending an untouched field on every save would re-serialize that already-lossy value a second time, while an untouched value is now never touched by JSON.stringify at all. ResourceForm also filters out any readOnly field itself, from rendering, from submit blocking and from the payload — it does not just trust that resource-create-state.ts/resource-edit-state.ts already filtered fields, since a consumer that passes the full, unfiltered schema should still not be able to send a read-only column by accident, nor have Save blocked with no visible message by a read-only column it never rendered. ResourceForm takes a required mode: 'create' | 'edit' input, used by shouldBlockSubmit(), the empty-payload-on-edit check and buildResourcePayload(). The exception: on create, an untouched boolean or number without hasDefault is sent as shown. An unchecked box reads as false and a number input shows its 0 seed, both real answers, not an absence: leaving them out made a not null column with no default fail the insert although the form showed a value, and stored NULL in a nullable one. An empty text-bound field shows nothing, so leaving it out still matches what the user saw. It is create-only: on edit the box shows the loaded value, re-sending it would make every untouched save a PATCH instead of a no-op, and on a column the user cannot update it would fail the same way an untouched created_at used to. save emits Partial<T>, not T — on edit (and now on create too) it genuinely can be a partial, and typing it as a full T would let a caller write value.name.toUpperCase() against a field that was never sent.
A field with hasDefault shows its placeholder seed value on create (defaultValueForField() in resource-create-state.ts), not the database's real default, which the client does not know. Showing it as an ordinary editable value would misleadingly suggest it is what gets saved. fieldHint() adds "Leave unchanged to use the database default." under the field (as a <mat-hint>/<span>, next to fieldError(), never both at once) whenever hasDefault is set and the field is still untouched.
Each UI layer has its own selector prefix
libs/material/src/lib/resource-table/resource-table.ts, libs/tailwind/src/lib/resource-table/resource-table.ts, both resource-form.ts, both eslint.config.mjs, libs/adapter-coexistence
Both adapters export a ResourceTable and a ResourceForm with the same inputs. With one shared selector, an app that imports both, or a test host that renders both, fails to compile with NG8023 (several components match one element), and a single pidrang- prefix would collide the same way. Each layer therefore uses its own prefix, pidrang-material- and pidrang-tailwind-, enforced by the component-selector and directive-selector lint rules of its library; a future UI package takes its own (pidrang-<layer>-). libs/adapter-coexistence is a private test project that renders all four components in one host: the two libraries depend only on core, so the check cannot live in either of them. Its build target is a no-op because Angular's unit-test builder reads a build target and the project has nothing to build.
Material table columns are identified by position, not by field name
libs/material/src/lib/resource-table/resource-table.ts
mat-table identifies each column by the name given to matColumnDef. Using the field name made a data field called actions collide with the projected actions column and fail with a duplicate column error. Data columns are now field-0, field-1, … by position, and the actions column keeps the name actions, which no data column can take. The cell still reads row[field.name], so only the internal identity changes, not what is shown. The actions column has an Actions header in both layers, so a screen reader announces the column of the row buttons.
ResourceForm is remounted per resource and per record, not reused
apps/demo-material/src/app/pages/resource-create-page.ts, resource-edit-page.ts, apps/demo-tailwind equivalents
The generic :resource/create and :resource/:id/edit routes are matched by the same route config regardless of which resource or id is in the URL, so Angular's router reuses the page component instance across a navigation between them — only its resource/id inputs change, the component (and everything inside it) survives. ResourceForm's model = form(this.draft, ...) is created once, in a field initializer, the first time the component is constructed; Signal Forms keeps each field's dirty/touched state keyed by field name across the model's lifetime. Navigating /products/create → /orders/create (or editing one product, then another) without recreating ResourceForm therefore left a same-named field (e.g. status) marked dirty from the previous resource or record, which could make an untouched field on the new one get sent as if the user had edited it. Each page wraps its ResourceForm element in @for (r of [key]; track r) { ... }, keyed on resource() (create) or `${resource()}:${id()}` (edit): changing the key changes what @for iterates, so Angular destroys and recreates the view — and the ResourceForm component inside it, with a fresh model — instead of patching it in place.
CLI
Introspecting Supabase through GraphQL
libs/cli-supabase/src/lib/graphql-schema-provider.ts
pg_graphql exposes each table as a Relay-style <table>Collection field on Query, returning { edges { node { ...columns, nodeId } } }. In the introspection result, edges is wrapped as NON_NULL(LIST(NON_NULL(OBJECT))) and node as NON_NULL(OBJECT), and __Type.fields is only populated for OBJECT and INTERFACE kinds (it is null on every wrapper). Reaching the columns therefore needs fields requested again after each ofType unwrap: Query, Collection, edges, Edge, node, Node, columns.
GraphQL has no recursive query syntax, so the query is generated to a fixed depth (8), enough to cover that path with margin. unwrapObjectType walks ofType until it finds a level where fields is populated.
The three schema providers split number/numeric and date/timestamp/time the same way
libs/cli-supabase/src/lib/postgres-field-type.ts, service-role-field-type.ts, graphql-field-type.ts
Each strategy used to fold every Postgres date/time type into FieldType.date and every integer/float/bigint/decimal type into FieldType.number. A bigint primary key (id included) then passed through JS Number, and a timestamptz column lost its time of day in the form (see the UI layers note on timestamp/time/numeric). The three mappers now agree on the same split: safe-precision integers and floats stay number; bigint, decimal and numeric map to numeric; date stays date; every timestamp/timestamptz variant maps to timestamp; every time/timetz variant maps to time — including the long-form time without time zone/time with time zone that service-role-field-type.ts originally missed (its TIMESTAMP_FORMATS had both forms; TIME_FORMATS did not). Postgres groups these by type name, PostgREST's OpenAPI schema by format string, and pg_graphql by scalar name (BigInt/BigFloat, Datetime, Time), so each mapper keeps its own lookup tables but the same three-way split. PostgREST 16 changed the format of the integer types: smallint and integer (with serial, identity and generated integer columns) became int32, and bigint became int64, where PostgREST 14 wrote smallint, integer and bigint. Every other supported type kept its format (checked against both versions with one column of each type). service-role-field-type.ts accepts both spellings, and int64 maps to numeric like bigint. Before that, every integer column of a project on PostgREST 16 was left out as unsupported, and with a default or NULL allowed it went missing without a word, since only the wizard named left-out columns.
This is why postgres-schema-provider.spec.ts's fixture for a bigint id column expects FieldType.numeric, not number: a bigint/numeric value already risks losing precision the moment PostgREST's JSON response is parsed (Postgres's own to_json writes it as a bare, unquoted JSON number literal, which JSON.parse then rounds to a double), so treating it as numeric end to end (schema, form, payload) at least stops the client from rounding it a second time. (An earlier version of this note claimed PostgREST returns int8/bigint as JSON strings — that is pg_graphql's behavior, which serializes BigInt/BigFloat scalars as strings per the GraphQL spec; the two Supabase layers disagree here, which is exactly why ResourceForm cannot rely on the wire shape and instead always treats numeric as a string it never parses, see the UI layers note above.)
readOnly, hasDefault and nullable are detected per strategy, to the extent each one can see them
libs/cli-supabase/src/lib/postgres-schema-provider.ts, service-role-schema-provider.ts, graphql-schema-provider.ts
generate-resources-file.ts emits readOnly, nullable and hasDefault for a generated column whenever the Field carries them, the same way it already did for hidden. Leaving every strategy's introspectTable returning bare { name, type } meant a table generated by pidrang init always came out with its primary key and generated columns editable: the create form then posted an empty id/created_at, which PostgREST rejects with a 22007 error on a column that cannot take an empty string. Each strategy now sets what it can actually see, rather than all guessing the same hardcoded id/created_at fallback:
- Postgres (
postgres-schema-provider.ts) has a live connection, so it is exact:information_schema.columnsis queried forcolumn_default,is_nullable,is_identity,identity_generationandis_generatedalongsidedata_type.readOnlyis the already-known primary key column, any column withis_generated = 'ALWAYS'(a trueGENERATED ALWAYS AS (expr) STOREDcolumn), or any column withidentity_generation = 'ALWAYS'(GENERATED ALWAYS AS IDENTITY, which Postgres rejects an explicit insert value for even on a non-primary-key column — aGENERATED BY DEFAULT AS IDENTITYcolumn stays writable, matching Postgres' own distinction).hasDefaultis a non-nullcolumn_defaultoris_identity = 'YES';nullableisis_nullable = 'YES'. - Service role (
service-role-schema-provider.ts) only has PostgREST's generated OpenAPI definition.readOnlyreuses the<pk/>marker already parsed out of the columndescriptionto find the primary key. The first version of this rule assumeddefinitions.<table>.requiredmeant "no default" and derivedhasDefaultfrom it — checked against the real local stack, that assumption was wrong: PostgREST'srequiredarray lists everyNOT NULLcolumn, default or not (price numeric(10,2) not null default 0is stillrequired), and each property instead carries its owndefaultkey ("default": "now()","default": 0) when the column has one. SohasDefault = 'default' in property, independent ofrequired;nullable = !required.includes(name), which is exactly whatrequireddoes mean on its own. Trusting the array's name instead of a real response was the mistake. Checked again for #52, the OpenAPI schema still hides three things: aGENERATED ALWAYS AS (expr) STOREDcolumn looks like a plain writable column, an identity column (idincluded) has nodefaultkey, and a default PostgREST cannot print as JSON ('{}'::jsonb) is missing too. A generatednumbercolumn therefore breaks create with this strategy (the form sends0, Postgres refuses it), and aGENERATED BY DEFAULT AS IDENTITYcolumn other thanidis asked for on create. Create stays available for an identityidwithout a visible default, because that is the most common Supabase table and the insert works without it. Use the direct database connection for tables with generated columns. - GraphQL (
graphql-schema-provider.ts) only haspg_graphql's introspection.nullableis read off whether the field's type is wrapped inNON_NULL.readOnlyisidplus every column missing from the<Node>InsertInputtype: pg_graphql leaves generated and identity columns out of it (aGENERATED BY DEFAULT AS IDENTITYcolumn too, so it becomes read-only here although Postgres would accept a value; it has a default, so nothing is lost).hasDefaultcannot be detected at all: everyInsertInputfield is optional whatever the column. Rather than guess, create is made unavailable as soon as a writableNOT NULLcolumn exists (idincluded when the insert input accepts it), and the wizard says to use the direct database connection for create. List, show and delete stay available, and so does edit as long as one column is writable, since an edit only sends the fields that changed. When the key cannot see theInsertInputtype, create is unavailable for the same reason.
Nothing publishes to npm yet, so no running consumer was affected by these gaps; the Postgres strategy is the one that sees everything.
On top of what the strategy sees, describe-table.ts marks created_at and updated_at read-only when they are a timestamp with a default (#125). Postgres accepts a value for them, so no strategy reports them as read-only, but the database or a trigger owns them, and a form field for them only invites a wrong date. The rule needs hasDefault, so it does not apply with GraphQL, which cannot see defaults. Labels are generated the same way for every strategy: readable-label.ts splits a name on _, -, . and lower-to-upper case changes, capitalizes the first word and lowercases the others (fixture_items gives Fixture items), and generate-resources-file.ts writes it on the Resource and on each Field.
The default list order is chosen when resources.ts is generated
libs/demo-shared/src/lib/resource-list-state.ts, libs/cli/src/lib/generate-resources-file.ts
The generic list page used to sort every resource by id. With uuid keys, that order is random, so a row created a second ago could land on any page (#111). The page cannot just sort by created_at: a table without that column would fail to load. The CLI, on the other hand, sees the columns of each table, so it writes defaultSorters on the Resource only when a created_at field of type timestamp or date exists, and the page uses defaultSorters when there is one and id otherwise. id stays as the second sorter because many rows can share the same created_at. A hidden created_at still counts, since a column can sort without being shown; a column left out because its type is unsupported does not, since it is not in fields. Other names (inserted_at, createdAt) are not detected: a hand-written defaultSorters covers them.
Unsupported columns are left out, never mapped to text
libs/cli/src/lib/describe-table.ts, libs/cli-supabase/src/lib/*-field-type.ts
The three mappers used to send any unknown type to text. A jsonb column then came back as [object Object] in an edit form and was written back as a string, an array lost its shape the same way, and an enum accepted any text. Each mapper now has a closed list of supported types (text-like, uuid, boolean, the number types, date, timestamp, time) and reports anything else as { unsupported: '<type>' }: json, jsonb, arrays, enums and other user-defined types (citext included), bytea, interval, inet, and so on. describeTable leaves those columns out of the generated Resource; the wizard names them in its actions question, and pidrang init --plan prints them before writing. Leaving a column out is only harmless when an insert can do without it, so a left-out column that is NOT NULL, has no default the strategy can see and is not generated makes create unavailable for that table instead of refusing the whole table; list, show, edit and delete still work. An id of an unsupported type refuses the table, since every page needs it. When nothing writable is left (only id, generated columns and left-out ones), edit is unavailable too, since its form would only show a Save button; create stays available, because an insert with no value works when every column has a default. Column names PostgREST reads as syntax (., ,, (, ), ", ->, the same rule as assertPostgrestField in @pidrang/supabase plus the quote, which a select cannot escape) are left out the same way; such a table name refuses the table. Postgres reports the supported subset through data_type and udt_name, PostgREST through format, type: 'array' and enum, and pg_graphql through the JSON and Opaque scalars and the ENUM and LIST kinds.
GraphQL names are checked against the REST API, not guessed
libs/cli-supabase/src/lib/graphql-schema-provider.ts
The generated pages read and write through PostgREST, so a name only matters if PostgREST knows it. pg_graphql can rename a table or a column (inflect_names, or a @graphql({"name": ...}) comment), and nothing in the GraphQL schema says it did. The first guard refused any table name with a capital letter: that refused a real "Invoices" table and let through a column renamed to a lowercase name. The strategy now asks PostgREST with the same anon key, GET /rest/v1/<table>?select="a","b"&limit=0, which reads no row and works under RLS: a 404 means the table name is GraphQL's own, a 42703 means a column name is, and either refuses the table with advice to use the direct database connection. A 401, 403 or 42501 means the key cannot read the table at all, and the table is refused too, rather than trusted. pg_graphql's mutations are not used to decide which actions to offer: they follow the anon role's grants, not the signed-in user's.
Connection settings are checked before any request
libs/cli-supabase/src/lib/connection-problems.ts, assert-valid-supabase-connection.ts
The prompts validate as the user types (a project URL with http(s):// and no path, query or credentials; a non-empty key; a port from 1 to 65535; a non-empty host, database and user), and createSupabaseSchemaProvider checks the same rules again, so a connection built without the prompts fails before any request. Trailing slashes are removed from the project URL, and the keys, host, database and user are trimmed before they are checked, so a key pasted with a trailing newline is not sent as is; the password is kept exactly as typed. A Number('abc') port used to reach pg as NaN. TLS always verifies the certificate: an empty CA path now means the system certificates, a path means that CA. No error message repeats a key or a password.
Generated pages copy apps/demo-material
libs/cli/src/lib/pages-bundle-sources.ts, libs/cli/src/lib/build-pages-bundle.ts, libs/cli/src/lib/generated-pages.ts, tools/sync-generated-pages.ts
The pages written by pidrang init are the generic pages of apps/demo-material. They are resource-agnostic and shared by every table, so they are generated once, not per table. pages-bundle-sources.ts lists which source file becomes which generated file, and pnpm run sync:pages rewrites generated-pages.ts from those sources. The published CLI only ships generated-pages.ts, so it never reads a repository file at run time. After changing a source page, run pnpm run sync:pages: the parity test in generate-pages-bundle.spec.ts (and pnpm run sync:pages --check) fails while the generated file is out of date.
Generated pages load lazily
RESOURCE_ROUTES_SNIPPET in libs/cli/src/lib/generate-pages-bundle.ts
resource-routes.ts used to import the four pages, so Angular Material's table and form code went into the initial bundle. An ng new app with the Supabase client, the pages and the layout came to 1.00 MB, above the 1 MB error budget ng new sets, so ng build failed right after pidrang init (found by the release gate's starter consumer). Each route now uses loadComponent, which brings the initial bundle of that app to 691 kB. It is still above the 500 kB warning budget: what stays is Angular, @supabase/supabase-js and the layout's Material modules, which the first screen needs anyway. Init does not change the app's budgets, which are the app's call.
The navigation layout is generated from the demos
apps/demo-material/src/app/layout/app-layout.ts, apps/demo-tailwind/src/app/layout/app-layout.ts, tools/sync-layout.ts, libs/cli/src/lib/generated-layout.ts, build-layout-module.ts, generate-layout-module.ts
Like the pages, the layout pidrang init writes is the demos' own: pnpm run sync:layout reads both demo layouts, inlines their TEMPLATE and STYLES constants into the @Component decorator, and writes them as two strings into generated-layout.ts. CI runs pnpm run sync:layout -- --check. The only change is the import of injectSignInUrl: the demos take it from @pidrang/demo-shared, the generated layout from '../pidrang/index', the page helpers barrel init writes with the pages, so the layout needs no file of its own besides src/app/layout/app-layout.ts. There is one variant per UI layer, chosen from the app's package.json (@pidrang/tailwind gives the Tailwind one), so a Tailwind app does not get Angular Material just for its shell.
The layout is written only on a new app (see A new app opens on its first list): an app with its own pages already has a shell, and a second header and menu around it would be wrong more often than right. Writing it takes four more edits, because a layout that is only written breaks the app:
app.htmlgets<app-layout appName="..." />, andapp.tsgetsAppLayoutin itsimports, edited on the syntax tree byedit-app-component.ts.RouterOutletis replaced when nothing else in the file uses it, since Angular warns about an unused standalone import (NG8113), and kept otherwise.app.spec.ts: theshould create the apptest ofng newcreatesApp, which now createsAppLayout, which callsinjectAuthProvider()and throws withoutprovidePidrang. Init addsTestBed.overrideComponent(App, { remove: { imports: [AppLayout] }, add: { schemas: [CUSTOM_ELEMENTS_SCHEMA] } })to thebeforeEach: the test still checksApp, without the layout. The demos' ownapp.spec.tsdo the same.app.config.tsgetsprovideSignInPath('/sign-in')only when init writes the sign-in page there (see The generated sign-in page). Writing the printed/your-sign-in-routeplaceholder instead would stop the app at startup, sinceprovideSignInPathrefuses it (see The sign-in link points only to a path the app provides).
The side menu hides on narrow screens
apps/demo-material/src/app/layout/app-layout.ts, apps/demo-tailwind/src/app/layout/app-layout.ts
Below 48rem the side menu starts closed, covers the page when opened and closes after a link or a tap outside it, and the focus goes back to the Menu button after a link, so a keyboard user is not left on an element that is gone; above it nothing changes from the first layout. The outside tap is a document:click host listener that ignores clicks inside the menu and on the Menu button (which toggles it itself), read from event.composedPath() rather than event.target, since a layout rendered inside a shadow root reaches the document with the host as its target. The width is read once, with matchMedia('(width < 48rem)') on inject(DOCUMENT).defaultView, when the layout is created: there is no resize listener, so a window resized after load keeps its menu state until the Menu button is pressed. The close after a link reads the same MediaQueryList, whose matches is live, so it follows the current width, not the width at load. Without a window (server rendering) or without matchMedia (jsdom, which the unit tests of an ng new app use), the menu starts open, as before. With server rendering, a phone therefore shows the menu for a moment, then hides it when the app takes over in the browser: the server cannot know the width, and rendering it closed there would hide it on every wide screen until then instead.
The breakpoint is written the same way in the script and in both styles, (width < 48rem), so they never disagree. The Tailwind layout uses max-[48rem]:, not max-md:: max-md follows the app's --breakpoint-md, so an app that changes it would get an overlay while the script still thinks the screen is wide.
The Material overlay is a grid, not a fixed position: below the breakpoint .layout becomes one column of two rows, .main gets display: contents so the toolbar and the page are the rows, and the menu sits in the page's cell above it. The menu then starts right below the toolbar whatever its height (Angular Material's toolbar is 56px below 600px, 64px above, and density changes both) and the Menu button stays reachable to close it. Its background is --mat-sys-surface, so the dark theme stays readable, with the Canvas system color as fallback for a theme built without system variables (mat.all-component-themes), so it is never transparent; Canvas follows the page's color-scheme, so a dark theme without system variables needs color-scheme: dark on the page to keep the overlay dark. The Tailwind menu links break long labels (break-words) instead of widening the overlay, and the overlay scrolls inside itself when the links are taller than the page below the header. Its header wraps below the breakpoint and its menu is capped at the screen width (max-w-full), since Tailwind's spacing is in rem and grows with the root font size. The page area is the scroll container there, as .main is on wide screens; the Tailwind main scrolls sideways below the breakpoint. Below the breakpoint the app name and the identity shrink and end with an ellipsis, so the header never pushes the document wider than the screen; above it they are shown in full, as in the first layout.
Left out, on purpose or for later: Escape does not close the overlay (the Menu button does); a ctrl-click on a link closes it too on a narrow screen, while a middle-click (an auxclick, not a click) leaves it open; in the Material layout the menu comes before the Menu button in the document, so Tab from the button does not enter the open overlay (Shift+Tab does), and moving it would change the wide layout. The release gate checks the generated Material layout at 390 px; the generated Tailwind layout is the demo-tailwind file byte for byte (sync:layout --check), and demo-tailwind runs the same 390 px checks.
Routing
The sign-in link points only to a path the app provides
libs/demo-shared/src/lib/sign-in-path.ts, parse-sign-in-path.ts, provide-sign-in-path.ts, sign-in-url.ts; the demos' own route is sign-in-route.ts
Generated pages cannot know where a consumer app signs users in, so SIGN_IN_PATH defaults to null and the pages then say "sign in" as plain text rather than linking to a route that may not exist. Unlike injectAuthProvider(), nothing throws when it is missing: no sign-in page is a valid setup. provideSignInPath parses the path once, when the app config is built, and the token holds the result, so a malformed value stops the app at startup instead of breaking every page, and pages only read it. It parses with DefaultUrlSerializer because it runs before any injector exists. The path is kept as a UrlTree resolved from the root: a string routerLink would be relative to the current page (/products/login). External URLs, ./.. segments, surrounding spaces and the /your-sign-in-route placeholder pidrang init prints are refused with a clear message; serializing the UrlTree already makes every href root-relative, so these refusals are readable errors, not the security boundary. A well-formed path to a route that does not exist cannot be detected there. Dot segments are checked on the parsed segments, after decoding, so %2e%2e is refused too. The path is validated with the default serializer, while RouterLink renders it with the app's own UrlSerializer, so an app with a custom serializer gets the href that serializer writes. The token lives in demo-shared and is copied into apps, not in @pidrang/core, which must not depend on the router. Each piece is its own file, following the code style's one export per file.
pidrang init checks everything before it writes anything
apps/pidrang-cli/src/preflight-app.ts, write-plan.ts, run-cli.ts
pidrang init used to write file by file and skip each file that already existed. An app generated by an older version, or a second run with other answers, then got a mix of old and new files that did not compile together, and only a message afterwards. It now works in two steps. Before any question, it reads angular.json (the single application, or the one --project names), takes that application's sourceRoot instead of assuming src, and checks the installed @angular/core (or the range in package.json when nothing is installed) is 22.1 or a later 22.x, like the peer ranges of the packages. It also checks that app.config.ts and app.routes.ts exist, which is how ng new lays out a standalone app with routing. The pages and helpers do not depend on the answers, so they are checked before the first question too; resources.ts can only be checked once the answers are known. Then every file is classified before the first write: missing, identical (ignoring Windows line endings) or in the way (different content, a folder with that name, or a file where a folder must go). One file in the way stops init with the full list and nothing written. Identical files are left as they are, so a second run with the same answers writes nothing and succeeds. Missing files are written with the wx flag, so a file created meanwhile is never overwritten. The app files init edits are read once and their exact text is kept in the plan; applyWritePlan re-reads each one just before writing and stops if it changed since, so a user saving the file during init is not overwritten, short of a save that lands between that re-read and the write (there is no lock, see the limit on two concurrent runs below).
A second run that adds tables used to stop on resources.ts, the one generated file that depends on the answers (#131). It is now edited on the syntax tree with the app's own typescript (edit-resources.ts): the new tables' literals, taken as text from what generateResourcesFile writes, are appended after the last element of export const resources = [...], after its trailing comma and any comment that starts on that line (// or /* */, read with getTrailingCommentRanges), with the existing indentation; nothing else in the file is touched, so a hand-edited literal stays byte for byte. A table already listed is kept as it is even when its columns changed, since the user may have edited it on purpose, and only named in the output; its answers are still collected and checked like any other's, so a reused plan whose hidden field no longer exists stops before resources.ts is read, and listing only the new tables avoids it; a table left out of the selection is never removed. The names must be certain, or a table could be listed twice (find returns the first one): only a single direct export const resources array, whose every element is an object literal with exactly one name, a string literal, and no spread or computed key is edited, not behind a type assertion other than as const (as AppResource[] only needs the types to overlap, so a table missing a required field would pass), only when resources is referenced nowhere else in the file (a property named resources, as in labels.resources, does not count) (a push or an alias could add or rename a table the array does not show), and only when the file has no syntax error (transpileModule diagnostics; a file with one is in the way). Anything else gets the // pidrang: comment, every line of it commented, listing the literals and asking to add only the tables not there yet. A resources.ts that does not certainly export a value named resources (an exported const, let or var binding of that name, destructured or not, or an export { x as resources } whose x is such a binding of the file; a type, a re-export or an export * is not certain, and without typescript the file must start, after any // pidrang: lines, with the import and the export const resources: Resource[] = [ init writes) is not a file init wrote: app.config.ts imports resources from it, so a comment there would leave an app that does not compile, and it stops init as a file in the way, like before #131. Then the file is type-checked with the app's typescript and the tsConfig of the application's build target in angular.json (from its defaultConfiguration first, the last of a comma-separated list winning as when ng build merges them, else from its options, else the root tsconfig.json), with the files it includes, so the app's own declarations count, and a probe file that assigns resources to PidrangConfig['resources'], the type providePidrang takes from the installed @pidrang/core (type-check-resources.ts, about two seconds), since transpileModule only sees syntax and the type a consumer needs is not visible in the file. Before the edit, an exported resources the probe rejects is not a list pidrang can read, so the file is in the way; when @pidrang/core cannot be resolved (its packages are not installed yet), the probe cannot tell, so init stops on resources.ts with nothing written and asks to install the packages and run it again; when the resolved type of the export is any or a list of any (a JSON.parse result or an any[], even behind an alias, passes any assignment), only a file that still starts with the import and the export const resources: Resource[] = [ init writes is edited, and any other is in the way too. Every such stop names its reason (no resources export, a syntax error, a type error, an export that is not a list of Resource, packages to install) instead of the generic differs from what this pidrang version generates. A file that does not compile on its own (a syntax error, or a type error other than @pidrang/core not being installed yet) is in the way too: the app may not compile it today, and init would make it import the file. After it, an error that was not there before, anywhere in the files the program covers (in resources.ts, the probe, or an app file that reads resources; a tuple type, a local Resource the generated literal does not fit, an inferred array whose fields[].type widens to string), turns the edit into the // pidrang: comment with the first new error as the reason; only syntax and type errors of the file and the probe are read (not option errors; incremental and composite are turned off, noCheck is forced off and strictNullChecks on for this check), a @ts-nocheck, @ts-ignore or @ts-expect-error in resources.ts gets the comment too, since it can hide the error a new table would raise (read from the comments of the syntax tree, not from strings or templates), and an edit that changes the type TypeScript gives the exported resources (an inferred array or an as const one widens or grows; a declared Resource[] does not) gets the comment too, since code or Angular templates that read it are not all type-checked here; errors already there do not count (each occurrence counts once, so a second error with the same text is new; the probe takes a file name the app does not use). The file is edited with \n and written back with its own line endings once. A later run on an app whose first run wrote the navigation layout leaves app.html alone when, comments aside, it holds only the <app-layout> host and layout/app-layout.ts is still the one init generates (so the outlet is there), so adding a table changes resources.ts only; any other app.html is handled as before, outlet instructions included.
resource-routes.ts depends on the answers too since the sign-in guard (#129): a second run sees an app that is no longer new, so it would generate the file without the guard and stop on it. When the existing file is exactly what init writes for some list of guarded tables, quote style aside (existing-signed-in-guards.ts regenerates it from the names it reads and compares with every string turned to single quotes, so a formatter that switched to double quotes still matches; an extended guard is written back with init's single quotes), that list is kept and the new private tables are added to it, even when this run's plan leaves out the sign-in page, since the guard and the page it sends to come from the first run, and the wizard asks the reads question again (askReadsNeedSignIn) since a later run is not a new app; an app whose first run guarded nothing keeps having no guard, as #129 decided for apps that are not new. A file edited by hand is still in the way. On an app that is not new, a new private table joins the guard only once resources.ts lists it (appended by this run or already there): while it only has the // pidrang: comment, on this run or a rerun before the change is made, the table is not added to the guard either (a guard naming a table the app does not list would send visitors of a route of the app's own with that path to sign in); init says so and the next run adds them.
Two pidrang init runs on the same app at the same time can still lose one run's edit: each re-reads a file just before writing it, but nothing locks it between that read and the write.
There is still no --force: replacing a file the user may have edited is their call, so the message says which folders to delete. A write can still fail half way (a full disk, Ctrl+C): init then lists what it wrote and what is left, and since what it wrote matches, running it again with the same answers finishes the job. Ctrl+C during the questions exits with 130 and nothing written.
A table named like a route the app declares (login) is refused like resource-unavailable: whichever route comes first would hide the other. The paths are read from app.routes.ts with a pattern, not parsed: every literal path: counts, by its first segment, including one in a child route or a comment, and a path built at run time is not seen. :param, ** and the empty path are ignored. Only app.routes.ts is read: a top-level route spread in from another file (...authRoutes) is not seen.
withComponentInputBinding() is added to the existing provideRouter(...) call, because the generated pages receive :resource and :id as inputs and stay blank without it. See pidrang init wires the app itself.
The wizard asks for the URL first and defaults to the service role key
libs/cli-supabase/src/lib/supabase-schema-provider-definition.ts, supabase-connection-error.ts
0.1.0 asked first how to read the schema, with GraphQL as the default, and only checked the connection once every question was answered. pg_graphql is not enabled on a new Supabase project (checked on 2026-10-21 on two cloud projects and on supabase start), so the default failed on a typical project, and the user had to start again without being told what to pick (#121). The wizard now asks the project URL first, then offers the service role (secret) key by default: it sits on the same dashboard page as the URL and needs nothing enabled. The direct database connection comes second, GraphQL third, marked as requiring pg_graphql.
The connection is tried (listTables()) before the wizard goes on, at the cost of one more schema request. The HTTP status alone does not say what went wrong: Supabase cloud answers {"errors": [{"message": "pg_graphql extension is not enabled."}]} and a local stack answers HTTP 200 with {"code": "PGRST202"} and no data, which used to come back as an empty table list. Both become a SupabaseConnectionError of kind graphql-disabled: the wizard says how to enable the extension and offers to continue with the secret key, keeping the URL; a refusal stops with that message. A 401, "No API key found" or "Invalid API key" is a rejected-key: the wizard asks the key again, with the reason above the prompt. Any other failure stops the run as before. A scripted init gets the same messages but never asks again. The direct database connection is not tried in the wizard.
A publishable key pasted as the service role key is the likeliest mistake, since both sit on the same page. A local stack serves the OpenAPI document to the anon role (GET /rest/v1/ answers 200 with an sb_publishable_ key or the legacy anon JWT, checked on 2026-10-21), so the server cannot be relied on to refuse it. The key is therefore refused before any request when it starts with sb_publishable_ or is a JWT whose payload has "role": "anon"; the payload is read, never verified, only to name the mistake. A network failure (DNS, refused connection, no answer after 15 seconds) is an unreachable-url: the URL is now the first answer, so asking for it again, prefilled, costs less than starting over, and the strategy and the key are kept.
Since the URL is asked whatever the strategy, the direct database connection keeps it as an optional url (also accepted in a plan), and supabaseAppSetup writes it into environment.ts in preference to the URL derived from the host, which only works for a db.<ref>.supabase.co host or a pooler user.
Publishable and secret keys, the legacy names kept as aliases
libs/cli-supabase/src/lib/supabase-schema-provider-definition.ts, supabase-app-setup.ts, supabase-connection-error.ts
The Supabase dashboard now hands out a publishable key (sb_publishable_...) and a secret key (sb_secret_...), and shows the anon and service role JWT keys as legacy. The new keys already worked (checked on 2026-10-21 with pidrang@0.1.0 and a local stack: an sb_secret_ key gives the same resources.ts as the service role JWT, and an sb_publishable_ key reads a table through @supabase/supabase-js), so only the names changed (#124): the prompts, environment.supabasePublishableKey in the generated app, PIDRANG_SUPABASE_PUBLISHABLE_KEY and PIDRANG_SUPABASE_SECRET_KEY, and the secret-key strategy of a plan. It was done before 0.1.1 because #107 starts writing the environment name into generated apps, where a later rename would break them.
A plan written for 0.1.0 keeps working: service-role, PIDRANG_SUPABASE_ANON_KEY and PIDRANG_SUPABASE_SERVICE_ROLE_KEY are still read, each with a warning on stderr through the optional warn argument of connectionFromPlan, so the provider never writes to the console itself. When both names of a key are set, the new one wins, so a CI that sets both while it migrates gets the new value and no warning. The connection types and the source files keep their service-role and anonKey names: they are not visible to a user, and renaming them would have widened the change for nothing.
The prefixes guard both directions: the secret-key strategy refuses an sb_publishable_ key or an anon JWT, and the GraphQL strategy refuses an sb_secret_ key or a service_role JWT, before any request, since a local stack accepts either. The app's environment never receives a key at all, only the YOUR-SUPABASE-PUBLISHABLE-KEY placeholder.
The messages point to where each value is: the project URL and the publishable key under the Connect button at the top of the dashboard (whatever framework is picked there, it shows both), the secret key under Project Settings, API Keys. The release gate generates its app with the stack's sb_secret_ key (SECRET_KEY of supabase status -o env, printed by the pinned CLI 2.119.0), so CI covers the recommended key, not only the legacy JWT.
An app wired by init before this rename keeps supabaseAnonKey: a later run sees providePidrang already in app.config.ts and leaves it, never edits an existing environment file, and only prints the supabasePublishableKey placeholder line. No release wrote supabaseAnonKey (0.1.0 did not wire the app), so there is no migration note.
pidrang init wires the app itself
apps/pidrang-cli/src/edit-app-config.ts, edit-app-routes.ts, edit-app-html.ts, edit-app-component.ts, edit-app-spec.ts, edit-environments.ts, libs/cli/src/lib/app-setup.ts, libs/cli-supabase/src/lib/supabase-app-setup.ts
The first version printed the snippets to paste into app.config.ts, app.routes.ts and app.html. #107 sets the acceptance rule at "no manual edit except in environment.xxx.ts", and a printed snippet fails that on every app: a missed paste, a provideRouter(routes) line left as it was, or ...resourceRoutes put below a ** route breaks every generated page without an error of its own. Init now edits the three files, and app.ts and app.spec.ts too when it adds the navigation layout.
Each provider returns an AppSetup (imports, constants, the properties of providePidrang, environment entries, notes) and the CLI writes the file from it, so @pidrang/cli still never imports a concrete provider and @pidrang/cli-supabase decides what a Supabase app needs: the client, the data provider, the auth provider, and the note that a browser app takes the anon key only. The anon key is always written as a placeholder. The service role strategy has that key in memory, and writing it into a bundle would hand every visitor full database access; the url is the project URL of the connection, derived from the database host or user when a direct connection has none and that names the project ref, and left as a placeholder with a note otherwise.
The edits go through the app's own typescript: init resolves it from the app, so the parser matches the app's syntax, and it changes ranges in the syntax tree rather than matching text with regular expressions. Positions are what make the edit idempotent: a second run finds the import, the constant and the provider already there and reports the file unchanged instead of adding a second copy. withComponentInputBinding() is added to the single existing provideRouter(...) call and keeps its other features.
Some files cannot be edited safely: no typescript installed next to the app, a providers array built at run time, several configs and none named appConfig, a name already used for something else in the file, app.html customized with no router outlet. Init then leaves the content alone and prepends a comment (// pidrang: in TypeScript, one <!-- pidrang: ... --> per line in HTML) listing the imports, the constant, the provideRouter line and the provider call to write. A later run strips and rebuilds that block, so comments never accumulate. The HTML lines used to be left open, so the rest of the template became a comment and ng build failed with NG5002; a block left that way by an older init is replaced too, and an outlet inside a comment does not count as one.
Replacing the welcome template breaks the should render title test of the app.spec.ts that ng new writes: it reads the welcome h1, which is gone, so a fresh app would fail ng test right after init. Init therefore removes that test, and only when it has just replaced the welcome template, so a spec is never touched in an app whose app.html it left alone. It recognizes the test by its title and by its querySelector('h1') call. Another test that reads the h1 is the user's own: init keeps it and lists it in a // pidrang: comment instead of deleting it.
Environment files are created only when the app has neither environment.ts nor environment.development.ts: an existing file may hold values or exports init must not overwrite, so init prints the missing lines instead. The two created files carry the same content, and the development one is added to fileReplacements of every build configuration except the default one, mirroring the Angular environment schematic.
A scripted init takes its answers from a file and its credentials from the environment
libs/cli/src/lib/init-plan.ts, wizard.ts, libs/cli-supabase/src/lib/supabase-schema-provider-definition.ts
pidrang init --plan plan.json answers the questions from a file, for CI or a repeatable setup. The file names the provider, the connection without its secrets, and per table the actions and hidden fields. It is checked whole before anything else, with every problem listed at once. Each provider turns the connection part into its own connection (connectionFromPlan). Secrets are read only from environment variables, and a secret written in the file is refused, so a plan can be committed. Error messages never repeat a connection value, and an invalid JSON file is reported without the parser's message, which quotes part of the file.
The plan goes through the same checks as the wizard: the table must be one the connection lists, its name must work as a route, at least one action, no action the table cannot support. A hidden field must be one of the fields that are kept. Actions and hidden fields are sorted the way the wizard returns them, and tables follow the order the provider lists them in. The same choices therefore give the same resources.ts in both modes. Actions are required: the wizard checks every available action by default, but a plan that silently gained an action when the schema changed would not be repeatable.
pidrang init offers to install the packages after writing
apps/pidrang-cli/src/install-packages.ts, node-version.ts, run-cli.ts, AppSetup.packages
The generated code imports @pidrang/core, the provider's packages and the UI layer, so a fresh app does not build until they are installed. The list comes from the provider (AppSetup.packages) and from the pages bundle (PAGES_UI_LAYER_PACKAGES), never from the bin, so @pidrang/cli stays provider-agnostic and a Tailwind bundle can bring its own list. The @pidrang packages are pinned to the version of the CLI because the generated pages and helpers are written for that version. Angular Material goes through ng add, which picks a version that fits the installed Angular and sets up a theme.
The install runs after the files are written: files that are already in place stay useful when the network or the registry fails, and the exact commands are printed instead. A failed install exits with 1 so CI notices. --plan never installs: it asks no question, and installing is a change to the app that a CI run should opt into by running the printed commands. Node.js is checked before anything else against the engines range, since npm only warns about it, and a test keeps the two in sync.
A second run must not undo what the app already set up: running the Material schematic again would ask its questions again and may add a theme next to the app's own. A package counts as present when package.json declares it and node_modules holds it, and an @pidrang one only at the version of the CLI. The package manager follows what the Angular CLI itself reads (cli.packageManager), then packageManager, then lockfiles up to the git root, so an app inside a pnpm or yarn workspace is not installed with npm. Outside a git repository only the app folder is searched, so a stray lockfile in a home directory does not decide. With Yarn Plug'n'Play there is no node_modules, so every package counts as missing: yarn add runs again, which changes nothing, and so does ng add @angular/material, which asks its questions again.
A new app opens on its first list
apps/pidrang-cli/src/edit-app-routes.ts (isNewApp, declaresEntryRoute), entry-redirect.ts, run-cli.ts
ng new leaves / on an empty view once the welcome template is gone, so a new app showed nothing until the user typed a resource URL (#125). Init now redirects '' to the first generated page, but only on a new app: an app.routes.ts that exports an empty routes array and an app.html that still holds the welcome template. An app with its own routes decides what it opens on, so init only prints the redirect line there, and not even that when app.routes.ts already declares a '' route. Without typescript, the empty array and the '' route are found with a pattern instead of the syntax tree.
The target is the list of the first table that has one, and the create page of the first table that has one only when no table has a list: a form is a poor first screen when a list exists further down. Tables keep the order the provider lists them in. When those reads need a signed-in user, the routes of that table are guarded instead, see A new app whose tables need a signed-in user opens on the sign-in page.
A new app whose tables need a signed-in user opens on the sign-in page
libs/demo-shared/src/lib/can-match-signed-in.ts, addSignedInGuard in libs/cli/src/lib/generate-pages-bundle.ts, signedInResources in apps/pidrang-cli/src/run-cli.ts, readsNeedSignIn in libs/cli-supabase/src/lib/postgres-schema-provider.ts, wizard.ts, init-plan.ts
A table whose select policy only admits authenticated reads comes back empty, or refused, to the anonymous visitor a new app starts as, so the app opened on a table with no rows (#129). The redirect from '' to the first list stays as it is; what changes is the route of a table marked as needing a signed-in user, which gets canMatchSignedIn([...]) in front of canMatchResourceAction(...). / then resolves through the redirect into a route whose guard sends a signed-out visitor to the sign-in path, and the sign-in page's own navigation to / lands on the first list once the user is signed in. Guarding per table rather than the whole app keeps a public table opening for a signed-out visitor next to a private one.
The names are written into resource-routes.ts at generation time rather than read at run time: the app's Resource list carries no policy information, and a browser cannot read policies. A table added later is covered only when the file is rewritten.
The guard lives in the copied helpers, not in @pidrang/core, which must not depend on the router, for the reason given in The sign-in link points only to a path the app provides. It is emitted only on a new app whose run writes the sign-in page, since a guard without a page sends the visitor to a route that does not exist, and later extended on a second run when resource-routes.ts is still the guarded file init wrote (see pidrang init checks everything before it writes anything): the wizard asks about reads only after the sign-in question, and a plan that marks tables private without writing a page prints which tables stay unguarded and why. It reads the identity with authProvider.getIdentity() and passes every navigation through when the app provides no sign-in path or no auth provider, when that call throws, and for a name it does not list: it is a convenience, not authorization, which stays with Row Level Security.
The database strategy answers per table from pg_class.relrowsecurity, the select policies for the anonymous role and the table GRANT: anon must hold select on the table, since a policy grants nothing on its own, and then row security off, or at least one permissive policy granted to anon or to public (the {0} role array) with no condition, and no restrictive policy, means readable; anything else means private. A policy with a condition of its own counts as private even when an anonymous visitor would pass it: deciding otherwise needs the expression evaluated against the data, not the catalog read, and an app that opens on an empty list is the failure this guards against. The secret-key strategy only has PostgREST's OpenAPI document and GraphQL only pg_graphql's introspection, neither of which shows policies, so they take the answer from the wizard question, asked once on a new app when the provider has none, or from readsNeedSignIn in a plan; it applies to every table of the run. The answer is not written into resources.ts: it changes where the app opens, not the schema.
Route order matters
apps/demo-material/src/app/app.routes.ts, apps/demo-tailwind/src/app/app.routes.ts, RESOURCE_ROUTES_SNIPPET in libs/cli/src/lib/generate-pages-bundle.ts
Angular matches routes in order and :resource matches any single segment. More specific patterns (:resource/create, :resource/:id/edit) must come before :resource/:id and :resource, and fixed paths such as login must come before :resource, or they are captured by it.
Action flags guard the resource routes
libs/demo-shared/src/lib/can-match-resource-action.ts, its generated copy in libs/cli/src/lib/generated-pages.ts
Resource.list, create, show, edit and delete default to enabled when omitted. Each resource route has a canMatch guard. For a segment that is not a registered resource, the guard returns false, so an app route declared after the resource routes (such as /settings) can still match. For a registered resource whose action is disabled, it redirects to RESOURCE_UNAVAILABLE_PATH (resource-unavailable) with skipLocationChange, so /products/create with create: false does not fall through to the show route with create as an id. The path lives in one constant so an app can rename it if it clashes with its own routes. Flags control what the UI offers; they are not authorization, which stays with Row Level Security.
Demo apps
price and total stay number, and keep no hasDefault
libs/demo-shared/src/lib/demo-resources.ts, pidrang/supabase/supabase/migrations/20260914123253_create_demo_tables.sql
Both columns are declared numeric(10, 2) not null default 0, which makes them a candidate for FieldType.numeric and hasDefault: true by the rules above, but demo-resources.ts keeps them number with no hasDefault, deliberately:
numeric(10, 2)never exceeds 8 integer digits and 2 decimal ones, which adoublerepresents exactly; the precision risknumericexists for does not apply here. The three schema providers still map every Postgresnumeric/decimalcolumn toFieldType.numericunconditionally, becauseinformation_schema.columns.data_typedoes not carry precision or scale without a further join they do not do today, so a generic, undetected column is treated as unsafe by default. A hand-writtenResource, which knows the real precision, can make the safer call directly; this is that call, not an inconsistency.- The
default 0on both columns is anot nullsafety net, not a value either field should be allowed to default to silently. Price and order total are the values these tables exist to record, so neither shows the "Leave unchanged to use the database default." hint:hasDefaultis reserved for a column the server, not the user, is expected to own, such ascreated_at. WithouthasDefault, an untouched price or total is sent as the0the form shows (see the payload note above), which is the same value thedefault 0would give.
The save lock keeps only running saves, with a 30-second timeout
libs/demo-shared/src/lib/save-lock.ts, save-store.ts
The create and edit pages lock a record while it is saved, across visits, so the user cannot send a second write that could land before the first. The lock lives in a root store, SAVE_STORE, that holds only the saves still running: a save removes its record when it settles. A page waiting on someone else's save keeps a reference to that save's entry, so it can still read the outcome after the entry left the store; the entry is collected with the page.
A request that never answers would leave every page of its record waiting with no explanation, so the provider call races a 30-second timer. On timeout the record stays locked, since the write may still land, but every page of that record, including one the user opens later, shows a message saying the save may still be applied above a form that stays disabled, so what the user typed is kept. When the request finally answers, its entry leaves the store, the form comes back and the page says whether that earlier save finished or failed; an edit page reads the record again after a success and keeps what was loaded after a failure. The late answer never opens the next page. The store therefore holds only requests in flight. A request that never settles (a fetch has no default timeout, a custom provider may retry forever) keeps its record locked until the page is reloaded, which the message says. The 30 seconds are a constant in the copied save-lock.ts: an app whose writes legitimately take longer edits it there. The timeout covers the request only. The navigation that follows keeps the lock until it ends, and a pending navigation ends as soon as another one starts.
The save is recorded as done as soon as the request succeeds, before the next page opens, so a failure in that step is never reported as a failed save to the pages waiting on it. A next page that does not open is reported only when no other navigation replaced it: the router resolves a replaced navigation with false too. A create page then shows "Created." instead of its draft, in case that other navigation is cancelled and the user stays.
The store counts session changes reported by the optional AuthProvider.onSessionChange. A save started in an earlier session is not reported to a page that was waiting on it, so the next user in the same tab never sees the previous user's error. The lock itself is kept until the save settles, and the page that started the save always shows its own result: hiding it could hide an expired-session error or let a create be sent twice. Supabase reports USER_UPDATED for the same user as a session change too, and delivers the event in a setTimeout, so a save settling in that same tick still shows its outcome; both are accepted. Without an auth provider nothing is hidden. The store is created in the root injector, so it only sees an authProvider passed to providePidrang at the root, as the demos and generated apps do; one provided in a lazy route is not seen.
A page learns that an earlier save ended by seeing its "waiting" state go from true to false. The linkedSignals behind it only notice that change when they are read, so a template must read earlierSave.failure() and earlierSave.finished() on every render, as the shipped pages do, not only under a condition.
The list pages use the same lock for delete, keyed on the resource: one delete runs at a time per resource, as before within a page, but the lock now holds across visits. After A → B → A, or on a list page opened while a delete of that resource runs, every delete button stays disabled and the page says a delete is still running; when it settles, the page says whether it finished or failed (with the same not-allowed or sign-in explanation as its own failures) and reads the list again after a success, or when the outcome is hidden because the session changed. The page that started the delete shows its own failure only while it is still open, on the same resource and in the same session, and a delete message already shown, its own or an earlier one, disappears when the session changes; the list is still read again after a success that settles in a new session. A delete that times out keeps the buttons disabled with a delete-specific message until the server answers. Two limits are shared with create and edit: an outcome is shown only to a page that was waiting when the operation settled, so a delete that finishes while the user is elsewhere is not reported (the list read on return shows its effect), and the key names the resource only, so two DataProviders exposing the same resource name in one app share one lock.
hold refuses a record that is already being saved, and a settling save only removes its own entry, so code calling injectSaveLock directly cannot unlock a newer save.
When the save succeeds but the next page fails to open, the error goes to Angular's ErrorHandler in a setTimeout, outside the save: an app whose ErrorHandler rethrows would otherwise turn a saved record into a reported save failure.
The generated sign-in page
apps/demo-material/src/app/pages/sign-in-page.ts, apps/demo-tailwind/src/app/pages/sign-in-page.ts, libs/cli/src/lib/build-sign-in-page.ts, tailwind-sign-in-page.ts, generate-sign-in-file.ts, planSignIn in apps/pidrang-cli/src/run-cli.ts
A new app has nothing to sign in with, so every write it makes is refused until the user hand-writes a form. Init therefore writes src/app/pages/sign-in-page.ts (#112). Like the layout, the Material page is the demo's own, derived from its source rather than copied: build-sign-in-page.ts keeps the imports and the component class of the demo page, inlines its TEMPLATE as a string literal with styles: STYLES, points the helpers import at '../pidrang/index', and drops what only a public demo needs, the shown demo account and its button, so a generated app never carries the demo's credentials. A spec runs it over the demo file, so a change to the demo page that the generated one cannot follow fails the build. The Tailwind page is a hand-written string instead: the demo's Tailwind page and the Material one are separate files with little in common to derive, and a Tailwind app must not import Angular Material for its form.
Init writes the page on a new app when the sign-in question (or signIn in a plan) says yes, and on an existing app unless the plan says no: an existing app wired by init has no page yet, and nothing about its own routes says whether it wants one, so the default is to write it and let the plan opt out. pidrang generate auth runs the same plan on demand, for an app init already set up. The path is bound with provideSignInPath('/sign-in') only when the page is written; a run without the page leaves app.config.ts alone, so the config never points at a route the app does not have.
The page is not written, and the run says why and goes on, when the plan asks for none, when app.routes.ts already declares a sign-in route (the app has its own page), or when the file is already there with other content (pidrang never overwrites). generate auth does the same without a plan, exiting 1 on files in the way. resource-routes.ts declares sign-in above the resource routes, so a resource named sign-in was already refused by init. The route is only written when pidrang writes the page: an app that keeps its own sign-in route would otherwise get a route importing a file that init does not generate, which the release gate's material consumer caught as a build error.
An app whose config is only a // pidrang: comment still needs the sign-in steps: the comment is rewritten from the calls found in the file itself, not from what the current run adds, so a second run that finds provideSignInPath or providePidrang missing in the code adds those steps and keeps the ones already there. Without TypeScript to read the file, the steps kept in the comment are all pidrang knows: the sign-in step is kept, with the path it already names, on a run that does not ask for the page, and a run that does ask for one writes its own path alongside it, so the app never gets a provideSignInPath('').
injectAuthSession() (libs/core/src/lib/auth-session.ts), which the generated page uses to show who is signed in, reads the identity with a resource() whose params is a local version signal, plus a linkedSignal that keeps the last known identity while a reload runs and drops it on an error. That means one getIdentity() call per injectAuthSession() caller, not one for the app: providePidrang({ authProvider }) also provides SESSION_IDENTITY, a signal started and subscribed to AuthProvider.onSessionChange at its first injection, so on the first read of any component that reads it, and unsubscribed on the destroy of the injector that provides it, the root one under bootstrapApplication. The session helper does not read it yet, so two components calling injectAuthSession() each load the identity themselves. Only the latest getIdentity() call may set it: every refresh numbers its request, and the answer to an older request is dropped, so a sign-out that has already been reported is not undone when the sign-in request before it answers late.
The demos' sign-in runs one at a time
libs/demo-shared/src/lib/sign-in.ts, sign-in-state.ts
The sign-in form submits natively, so Enter in either field signs in. While a sign-in runs, a root state, SIGN_IN_STATE, disables the form and the demo-account button on every visit of the page and shows "Signing in…". Supabase saves the session of each successful sign-in as it answers, so two sign-ins in flight could end on the older account; one at a time prevents that. Both demos call the same injectSignIn(). The lock is released when the request settles, not when the page is left. A success goes home only if a sign-in page is still open, the one that started it or a later visit, so a user who came back to the page while it ran is not left on it signed in, and one who went elsewhere is not pulled away. A failure is kept in the same root state, so a visit that came back while the sign-in ran shows it; it is kept only when a sign-in page is open to show it, and cleared by the next attempt, by a success, and when the last open sign-in page is left, so a later visit never shows an old error. A session change (another tab signing in, a sign-out) clears it too, and a failure from before that change is dropped. After 30 seconds without an answer, the page says to reload it and keeps the form disabled: a late answer would still change the session, so the lock is never released early, but reloading is a safe way out, since the session is saved by the page's own script and a reload drops the request before its answer is saved. The demos' sign-in page is the one pidrang init generates, see The generated sign-in page.
The release gate owns its servers and its database
tools/release-gate
The gate starts every server it tests on its own port (4310, 4311, 4320, 4321, 4322, 4323) and refuses to reuse one already running. A nx serve left running on 4200 points at the hosted project, and Playwright's reuseExistingServer would test that instead. For the same reason, every page blocks requests to non-loopback hosts. The fixtures are reloaded before each test, so the tests share one database and must run one at a time: the config sets one worker and one browser.
The gate keeps the tarballs it tested next to their sha256 in identities.json, rather than only checking them and deleting them like verify:artifacts. Publishing can then prove it ships the bytes that were tested.
The installed apps are copied outside the repository before npm install. Inside it, Node would resolve any package missing from the app in the repository's own node_modules, and a missing peer dependency would go unnoticed.
The CI and Release gate workflows install npm 11.21.1 over the npm 10 that comes with Node 22. Since vitest 4.1.12 (2026-10-15), npm 10 crashes with Cannot read properties of null (reading 'edgesOut') while it resolves the optional peer dependencies of @angular/build, even in an app with no pidrang package. npm 11 and 12 install the same apps. The version is exact so a new npm release cannot change what the gate tests, and identities.json records it.
The Material and Tailwind consumers are fixtures: apps already wired by hand, so they test the packages and the generated files, not what init does to an app. The starter consumer (full mode only, since init needs the local stack) runs ng new with the workspace's Angular CLI, installs the tarballs, runs pidrang init --plan with the navigation layout, and fails when init leaves a pidrang: comment in an app file. It then runs ng test and ng build, and Playwright checks the redirect, the layout and the sign-in link. A second starter, starter-private, does the same with generation-plan-private.json and consumer.mjs --reads-need-sign-in, which sets the plan key: its staff_notes table is readable only by a signed-in user, so Playwright checks that / and /staff_notes open the sign-in page for a signed-out visitor, and that signing in lands on the first list, which is the private table itself, with the other lists still loading. Unit tests of each edit passed while a new app did not compile, failed ng test, stopped at startup and exceeded the bundle budget: only a real ng new app showed it.
The old apps/demo-material-e2e and apps/demo-tailwind-e2e projects were removed. Their only test looked for a "Welcome" heading that no longer exists, and they served the demos against the hosted project. The demo smoke tests now live in the gate, against the local fixtures.
Local Supabase key
apps/demo-material/src/environments/environment.local.ts, apps/demo-tailwind/src/environments/environment.local.ts
The sb_publishable_... key in the local environment is the default key generated by supabase start against the unmodified config.toml of the pidrang/supabase repository. It is the same for every contributor, is only valid against 127.0.0.1 and requires the local Supabase containers to be running. It is not a secret, and secret scanners that flag it can be ignored for this file.
The key in environment.ts (supabasePublishableKey) is the public publishable key of the hosted demo project. It is meant to ship to browsers; what it can do is limited by Row Level Security on that project.
