@everystack/model 0.4.16 → 0.4.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -110,6 +110,50 @@ defineModel('posts', {
110
110
 
111
111
  `privileges` takes only those three, and it is additive — it widens what a role holds and can never narrow it. DML stays in `can()`, where the grant and the row policy are decided together. `db:pull --abilities live` writes this key from the live grants, so an existing database that grants its admin role all three keeps them.
112
112
 
113
+ ## Soft delete
114
+
115
+ A `deletedAt` column is a column. `softDelete` is the decision about what it MEANS, and it sits
116
+ on the model beside `fields`:
117
+
118
+ ```typescript
119
+ export const Upload = defineModel('uploads', {
120
+ fields: {
121
+ id: field.uuid().primaryKey().defaultRandom(),
122
+ accountId: field.uuid().notNull().references(() => Account),
123
+ s3Key: field.text().notNull(),
124
+ deletedAt: field.timestamptz(),
125
+ },
126
+ softDelete: true,
127
+ abilities: [can('read', { owner: 'accountId' }), can('delete', { owner: 'accountId' })],
128
+ });
129
+ ```
130
+
131
+ It changes two different things, and neither default is safe:
132
+
133
+ - **Durability, on every model** — `true` makes a `DELETE` mark `deleted_at` instead of removing
134
+ the row, and hides marked rows from the data API's reads and updates. `false` makes `DELETE`
135
+ permanent.
136
+ - **Visibility, only where there is a public read** — `true` AND-s `deleted_at IS NULL` into the
137
+ anon policy. On an owner-scoped or admin-only model the compiled SQL is identical either way,
138
+ so durability is the axis that matters there.
139
+
140
+ **`defineModel` refuses to guess** when a model has `deletedAt` AND either a public read or a way
141
+ to DELETE (`can('delete')`, `can('manage')`, or a `privileges` DELETE grant). Omit the line on
142
+ such a model and it throws at import, naming which branch it tripped. Where nothing reads
143
+ publicly and nothing deletes, the flag provably changes neither the SQL nor the handler, so
144
+ nothing is required.
145
+
146
+ Pick `true` if the row is meant to survive its own deletion — anything with a cleanup or purge
147
+ job, an audit trail, or rows other tables reference. Pick `false` if `deleted_at` here is
148
+ audit-only and a `DELETE` really should remove the row. It is not cosmetic: a table whose S3
149
+ cleanup job finds work by `deleted_at IS NOT NULL` orphans every file it should have purged if
150
+ the row is hard-deleted instead.
151
+
152
+ A column named `deleted_at` is not a statement of intent — it is carried for audit, for
153
+ app-layer filtering, for half-finished migrations. This used to be inferred from the field's
154
+ presence, which made the compiler author a security rule nobody wrote. `db:pull` writes the
155
+ flag explicitly for the same reason.
156
+
113
157
  ## Relations
114
158
 
115
159
  ```typescript
@@ -137,13 +181,18 @@ everystack db:pull --stage dev # reverse an existing database into model s
137
181
 
138
182
  ### Handler config
139
183
 
140
- `deriveHandlerConfig(models)` compiles the same Models into the authorization config `createHandler` consumes (exposed tables, hidden/protected columns, relations, row ownership) — so the HTTP handler and the database enforce the *same* declared rules, with no second source of truth.
184
+ `deriveHandlerConfig(models)` compiles the same Models into the authorization config `createHandler` consumes (exposed tables, hidden/protected columns, relations, row ownership, **`softDelete.tables`**) — so the HTTP handler and the database enforce the *same* declared rules, with no second source of truth.
141
185
 
142
186
  ```typescript
143
187
  import { deriveHandlerConfig } from '@everystack/model';
144
188
  const handler = createHandler(db, schema, deriveHandlerConfig(models));
145
189
  ```
146
190
 
191
+ Do not hand-write the per-table keys beside it. Each one is a view of the models, and a
192
+ hand-kept copy drifts silently: `softDelete.tables` losing two entries is what let a table start
193
+ hard-deleting rows whose S3 objects a purge job keyed on `deleted_at IS NOT NULL`. Handler-level
194
+ options that are not per-table — `auth`, `pgSettings`, `rpc`, limits — stay explicit.
195
+
147
196
  ## Programmatic API (`@everystack/cli/model`)
148
197
 
149
198
  The same compiler the CLI uses is importable for your own CI and tests — point it at your live DB and assert the declaration and the database agree, or red-team the deployed authorization:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/model",
3
- "version": "0.4.16",
3
+ "version": "0.4.17",
4
4
  "description": "Declare a table's schema, validation, and authorization once — compiled to migrations, RLS, and handler config",
5
5
  "license": "AGPL-3.0-only",
6
6
  "publishConfig": {
@@ -12,10 +12,12 @@
12
12
  ],
13
13
  "exports": {
14
14
  ".": {
15
+ "source": "./src/index.ts",
15
16
  "types": "./src/index.ts",
16
17
  "default": "./src/index.ts"
17
18
  },
18
19
  "./drizzle": {
20
+ "source": "./src/drizzle.ts",
19
21
  "types": "./src/drizzle.ts",
20
22
  "default": "./src/drizzle.ts"
21
23
  }
package/src/derived.ts CHANGED
@@ -416,6 +416,15 @@ export interface FunctionDefinition {
416
416
  * PUBLIC-executable. Opinion lives in the linter, never in the parser.
417
417
  */
418
418
  privileges?: Record<string, string[]>;
419
+ /**
420
+ * Owner-only by design — no app role may ever EXECUTE this function.
421
+ *
422
+ * Parallel to `private` on views/matviews. An `internal` function is a helper called only
423
+ * by other SECURITY DEFINER functions running as the owner. The compiler still emits
424
+ * `REVOKE ALL … FROM PUBLIC`; `internal` tells the lint (`db:check`) the absence of callers
425
+ * is reviewed and permanent, not an oversight. Contradicts `abilities` and `privileges`.
426
+ */
427
+ internal?: boolean;
419
428
  comment?: string;
420
429
  body: RawSql;
421
430
  }
@@ -436,6 +445,7 @@ export interface FunctionDescriptor {
436
445
  abilities: Ability[];
437
446
  /** Recorded EXECUTE grants — see {@link FunctionDefinition.privileges}. */
438
447
  privileges: Record<string, string[]>;
448
+ internal: boolean;
439
449
  comment?: string;
440
450
  body: string;
441
451
  }
@@ -487,6 +497,12 @@ export function defineFunction(name: string, def: FunctionDefinition): FunctionD
487
497
  if (seen.has(a.name)) throw new Error(`function '${name}': duplicate argument '${a.name}'.`);
488
498
  seen.add(a.name);
489
499
  }
500
+ if (def.internal && (def.abilities?.length ?? 0) > 0) {
501
+ throw new Error(`function '${name}': internal: true declares it owner-only — it cannot also declare abilities. Drop one.`);
502
+ }
503
+ if (def.internal && Object.keys(def.privileges ?? {}).length > 0) {
504
+ throw new Error(`function '${name}': internal: true declares it owner-only — it cannot also declare privileges. Drop one.`);
505
+ }
490
506
  for (const a of def.abilities ?? []) {
491
507
  if (a.action !== 'execute') {
492
508
  throw new Error(`function '${name}': can('${a.action}') is not a function ability — functions carry can('execute', { role }) only.`);
@@ -526,6 +542,7 @@ export function defineFunction(name: string, def: FunctionDefinition): FunctionD
526
542
  dependsOn: def.dependsOn ?? [],
527
543
  abilities: def.abilities ?? [],
528
544
  privileges,
545
+ internal: def.internal ?? false,
529
546
  ...(def.comment ? { comment: def.comment } : {}),
530
547
  body: def.body.sql,
531
548
  };