@everystack/model 0.3.4 → 0.3.6

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
@@ -72,7 +72,9 @@ export const models = [Account, Note];
72
72
  | `field.jsonb()` | `jsonb` |
73
73
  | `field.enum('name', [...])` | a `CREATE TYPE … AS ENUM` (name is explicit, so `db:pull` round-trips it) |
74
74
 
75
- Modifiers: `.primaryKey()`, `.notNull()` / `.nullable()`, `.unique()`, `.default(value)` / `.defaultNow()` / `.defaultRandom()`, `.references(() => Other, { onDelete, onUpdate })`, `.private()` (never serialized), `.readonly()` (never accepted on write), `.validate(zodSchema)`, and `.renamedFrom('oldField')` (turns a drop+add into a data-preserving `RENAME COLUMN`).
75
+ Modifiers: `.primaryKey()`, `.notNull()` / `.nullable()`, `.unique()`, `.default(value)` / `.defaultNow()` / `.defaultRandom()`, `.references(() => Other, { onDelete, onUpdate })`, `.private()` (never serialized), `.readonly()` (never accepted on write), `.validate(zodSchema)`, `.renamedFrom('oldField')` (turns a drop+add into a data-preserving `RENAME COLUMN`), and `.deprecated()` (the contract phase of contraction: the column stays in the database and stays readable — old clients keep working — while new writes are rejected and generated types strike it through; deleting the field later is the actual drop, destructive and gated).
76
+
77
+ Renaming a whole table works the same way, at the model level: `defineModel('articles', { renamedFrom: 'posts', ... })` compiles to one `ALTER TABLE … RENAME TO …` (data preserved, policies ride along) instead of CREATE plus a held DROP. Both markers are one-shot — remove them once every environment is past the state; a satisfied rename is an inert notice.
76
78
 
77
79
  `.validate(...)` does double duty: the SQL-expressible refinements (`min`/`max`/`length`/range/enum) become a Postgres `CHECK` — the backstop for the SSR-direct write path that never runs app-layer Zod — while richer refinements stay app-layer only.
78
80
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/model",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
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": {
package/src/derive.ts CHANGED
@@ -66,8 +66,10 @@ export function deriveHandlerConfig(models: ModelDescriptor[]): DerivedHandlerCo
66
66
  const privateCols = fieldsWhere(model, (f) => f.spec.isPrivate);
67
67
  if (privateCols.length) hiddenColumns[table] = privateCols;
68
68
 
69
- // .readonly() → protectedFields (mass-assignment guard).
70
- const readonlyCols = fieldsWhere(model, (f) => f.spec.isReadonly);
69
+ // .readonly() → protectedFields (mass-assignment guard). A deprecated field
70
+ // joins it — the contract phase rejects new writes but keeps reads serving
71
+ // (stale clients read for weeks; hiding it would break them before the drop).
72
+ const readonlyCols = fieldsWhere(model, (f) => f.spec.isReadonly || f.spec.isDeprecated);
71
73
  if (readonlyCols.length) protectedFields[table] = readonlyCols;
72
74
 
73
75
  // The first owner-shaped ability → the handler's rowOwnership IDOR backstop.
package/src/field.ts CHANGED
@@ -55,6 +55,14 @@ export interface FieldSpec {
55
55
  isPrivate: boolean;
56
56
  /** `.readonly()` — never accepted on write (→ `protectedFields`). */
57
57
  isReadonly: boolean;
58
+ /**
59
+ * `.deprecated()` — the contract phase of contraction. The column stays in the
60
+ * database and stays READABLE (stale clients keep working); it leaves the write
61
+ * surface (→ `protectedFields`) and is struck through in generated types
62
+ * (`@deprecated` in the drizzle schema). Deleting the field from the model is
63
+ * the physical drop — a destructive, role-gated act.
64
+ */
65
+ isDeprecated: boolean;
58
66
  /** `.references(() => Other)` — FK target, a model thunk. Used by relation/ownership work. */
59
67
  references?: () => unknown;
60
68
  /** `ON DELETE` action for the FK. Omitted (or `'no action'`) is the Postgres default. */
@@ -97,6 +105,7 @@ export class FieldBuilder {
97
105
  isArray: false,
98
106
  isPrivate: false,
99
107
  isReadonly: false,
108
+ isDeprecated: false,
100
109
  };
101
110
  }
102
111
 
@@ -139,6 +148,18 @@ export class FieldBuilder {
139
148
  return this;
140
149
  }
141
150
 
151
+ /**
152
+ * Mark the contract phase of contraction (expand → deprecate → drop). The column
153
+ * stays in the database and stays readable — old clients keep working — but new
154
+ * writes are rejected (`protectedFields`) and generated types strike it through.
155
+ * The physical drop is deleting the field from the model: a destructive plan,
156
+ * confirmed, snapshotted, and approver-gated on protected stages.
157
+ */
158
+ deprecated(): this {
159
+ this.spec.isDeprecated = true;
160
+ return this;
161
+ }
162
+
142
163
  /**
143
164
  * Declare a foreign key. The target is a model thunk (forward-reference safe). Optional
144
165
  * `onDelete`/`onUpdate` referential actions are column-scoped, so they live here, not on
package/src/model.ts CHANGED
@@ -63,6 +63,15 @@ export interface ModelDefinition {
63
63
  * can span schemas — the model's schema always wins over a generate call's default.
64
64
  */
65
65
  schema?: string;
66
+ /**
67
+ * `renamedFrom: 'old_table'` — a one-shot migration hint, the table-level
68
+ * analog of `field.renamedFrom()`. A diff cannot distinguish RENAME from
69
+ * DROP+ADD; the marker carries the intent, so the diff emits one
70
+ * `ALTER TABLE … RENAME TO …` (data preserved) instead of CREATE plus a
71
+ * held DROP. Same schema as the model. Remove it once every environment
72
+ * is past the state — a satisfied rename is an inert notice.
73
+ */
74
+ renamedFrom?: string;
66
75
  /**
67
76
  * `true` removes the table from the generic data API — `deriveHandlerConfig`
68
77
  * skips it, so it never enters `exposedTables`. Default `false`. Package-owned
@@ -90,6 +99,8 @@ export interface ModelDescriptor {
90
99
  schema: string;
91
100
  /** `true` removes the table from the generic data API (not in `exposedTables`). Default `false`. */
92
101
  private: boolean;
102
+ /** One-shot rename hint: this table used to be named `renamedFrom` (same schema). */
103
+ renamedFrom?: string;
93
104
  }
94
105
 
95
106
  export function defineModel(table: string, def: ModelDefinition): ModelDescriptor {
@@ -108,5 +119,6 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
108
119
  writtenBy: def.writtenBy ?? 'app',
109
120
  schema: def.schema ?? 'public',
110
121
  private: def.private ?? false,
122
+ ...(def.renamedFrom ? { renamedFrom: def.renamedFrom } : {}),
111
123
  };
112
124
  }