@pramen/server 0.0.4 → 0.0.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/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, defaultTo, primaryKey, generated } from "./sdk/schema";
1
+ export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, defaultTo, primaryKey, generated, expr, ExprDefault } from "./sdk/schema";
2
2
  export { isValidUuid } from "./sdk/uuid";
3
3
  export type { DefaultValue, FieldType, FieldDef, EntityFields, EntityDef, SchemaDef, RelationDef, RelationDefs, BelongsToDef, HasManyDef, } from "./sdk/schema";
4
4
  export { createApp } from "./sdk/app";
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@
7
7
  // which only exists in the Workers runtime; keeping it separate lets the CLI, tests,
8
8
  // and codegen load an app.ts for its schema without dragging in the DO runtime.
9
9
  // --- schema authoring ---
10
- export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, defaultTo, primaryKey, generated } from "./sdk/schema";
10
+ export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, defaultTo, primaryKey, generated, expr, ExprDefault } from "./sdk/schema";
11
11
  export { isValidUuid } from "./sdk/uuid";
12
12
  // --- app + handlers ---
13
13
  export { createApp } from "./sdk/app";
@@ -19,10 +19,16 @@ function defaultLiteral(v) {
19
19
  return String(v);
20
20
  return `'${v.replace(/'/g, "''")}'`;
21
21
  }
22
- /** The ` DEFAULT x` fragment for a column, or "" when it has no default. UNIQUE/
23
- * index are NOT inline — they're emitted as separate index statements so the same
24
- * code path serves both CREATE TABLE and ALTER TABLE ADD COLUMN. */
22
+ /** The ` DEFAULT x` fragment for a column, or "" when it has no default. A raw-SQL
23
+ * `defaultExpr` (e.g. `datetime('now')`) is emitted UNQUOTED; a literal `default` is
24
+ * quote-escaped. UNIQUE/index are NOT inline they're emitted as separate index
25
+ * statements so the same code path serves both CREATE TABLE and ALTER TABLE ADD COLUMN. */
25
26
  function defaultSql(f) {
27
+ // A raw-SQL default is parenthesized: SQLite's column-DEFAULT grammar only takes a
28
+ // bare literal/keyword, so a function call (e.g. datetime('now')) must be wrapped —
29
+ // `DEFAULT (datetime('now'))`. Parens are harmless around a keyword too.
30
+ if (f.defaultExpr !== undefined)
31
+ return ` DEFAULT (${f.defaultExpr})`;
26
32
  return f.default !== undefined ? ` DEFAULT ${defaultLiteral(f.default)}` : "";
27
33
  }
28
34
  function columnSql(name, f) {
@@ -114,9 +114,18 @@ export async function migrate(driver, schema, opts = {}) {
114
114
  continue;
115
115
  }
116
116
  // Pass 1 — additive: add any column the schema declares but the table lacks.
117
+ // SQLite forbids ALTER ADD COLUMN with a non-constant DEFAULT (e.g. expr.now()),
118
+ // so such a column is added via a table rebuild instead — which is still additive
119
+ // (no data loss): the rebuild's INSERT omits the new column, so SQLite applies its
120
+ // CREATE TABLE default, backfilling existing rows. Flagged here, done in Pass 2.
121
+ let needsAdditiveRebuild = false;
117
122
  for (const [name, field] of Object.entries(def.fields)) {
118
123
  if (existing.has(name))
119
124
  continue;
125
+ if (field.defaultExpr !== undefined) {
126
+ needsAdditiveRebuild = true;
127
+ continue;
128
+ }
120
129
  await driver.exec(`ALTER TABLE ${ident(table)} ADD COLUMN ${addColumnSql(name, field)}`, []);
121
130
  added.push(`${table}.${name}`);
122
131
  }
@@ -132,14 +141,17 @@ export async function migrate(driver, schema, opts = {}) {
132
141
  }
133
142
  const needsDrop = [...live.keys()].some((c) => !desired.has(c) && !renamedSources.has(c));
134
143
  const needsTypeChange = Object.entries(def.fields).some(([n, f]) => live.has(n) && live.get(n) !== sqlType(f));
135
- if (needsDrop || needsTypeChange || renamedSources.size > 0) {
136
- if (allowDestructive) {
137
- await rebuildTable(driver, table, def, live);
138
- rebuilt.push(table);
139
- }
140
- else {
141
- skipped.push(`rebuild ${table} (drop/type-change/rename)`);
142
- }
144
+ const destructive = needsDrop || needsTypeChange || renamedSources.size > 0;
145
+ if (destructive && !allowDestructive) {
146
+ // The destructive part is gated off — skip the whole rebuild (any pending
147
+ // expr-default column waits until destructive migrations are allowed).
148
+ skipped.push(`rebuild ${table} (drop/type-change/rename)`);
149
+ }
150
+ else if (destructive || needsAdditiveRebuild) {
151
+ // An additive-only rebuild (just an expr-default column) needs no permission —
152
+ // it loses no data.
153
+ await rebuildTable(driver, table, def, live);
154
+ rebuilt.push(table);
143
155
  }
144
156
  }
145
157
  // Ensure unique/index declarations (idempotent, via IF NOT EXISTS). Indexes can be
@@ -65,6 +65,8 @@ type RequiredInsertKeys<F extends EntityFields> = {
65
65
  generated: true;
66
66
  } ? never : F[K] extends {
67
67
  default: DefaultValue;
68
+ } ? never : F[K] extends {
69
+ defaultExpr: string;
68
70
  } ? never : K : never;
69
71
  }[keyof F];
70
72
  type OptionalInsertKeys<F extends EntityFields> = Exclude<keyof F, RequiredInsertKeys<F>>;
@@ -16,6 +16,10 @@ export interface FieldDef {
16
16
  readonly generated?: boolean;
17
17
  /** A column DEFAULT (a literal). Makes the column optional on insert. */
18
18
  readonly default?: DefaultValue;
19
+ /** A column DEFAULT that is raw SQL, emitted UNQUOTED (e.g. `datetime('now')`) —
20
+ * set by `defaultTo(field, expr.now())`/`expr.raw(...)`. Distinct from `default`
21
+ * (a quoted literal). Makes the column optional on insert. */
22
+ readonly defaultExpr?: string;
19
23
  /** Migration hint: this column was previously named X. On boot the migrator
20
24
  * rebuilds the table, copying data from the old column. A diff cannot tell a
21
25
  * rename from a drop+add, so the rename must be declared explicitly. */
@@ -120,7 +124,26 @@ export declare function unique<F extends FieldDef>(field: F): F & {
120
124
  export declare function indexed<F extends FieldDef>(field: F): F & {
121
125
  readonly index: true;
122
126
  };
123
- /** Give the column a DEFAULT (a literal) also makes it optional on insert. */
127
+ /** A raw-SQL column DEFAULT (emitted unquoted in the DDL), produced by the `expr`
128
+ * helpers below. Distinct from a literal default so `defaultTo` can render
129
+ * `DEFAULT datetime('now')` rather than the quoted string `DEFAULT 'datetime(...)'`. */
130
+ export declare class ExprDefault {
131
+ readonly sql: string;
132
+ constructor(sql: string);
133
+ }
134
+ /** SQL-expression defaults for `defaultTo(field, expr.now())`. `now()` is the current
135
+ * UTC timestamp as TEXT (`'YYYY-MM-DD HH:MM:SS'`, like `CURRENT_TIMESTAMP`) — pair it
136
+ * with `t.text()`. `raw(sql)` is an escape hatch for any other SQLite default expression. */
137
+ export declare const expr: {
138
+ now: () => ExprDefault;
139
+ raw: (sql: string) => ExprDefault;
140
+ };
141
+ /** Give the column a DEFAULT — also makes it optional on insert. Pass a literal
142
+ * (rendered as a quoted SQL literal) or an `expr.*()` value (raw SQL, unquoted),
143
+ * e.g. `defaultTo(t.text(), "pending")` or `defaultTo(t.text(), expr.now())`. */
144
+ export declare function defaultTo<F extends FieldDef>(field: F, value: ExprDefault): F & {
145
+ readonly defaultExpr: string;
146
+ };
124
147
  export declare function defaultTo<F extends FieldDef, D extends DefaultValue>(field: F, value: D): F & {
125
148
  readonly default: D;
126
149
  };
@@ -57,9 +57,24 @@ export function unique(field) {
57
57
  export function indexed(field) {
58
58
  return { ...field, index: true };
59
59
  }
60
- /** Give the column a DEFAULT (a literal) also makes it optional on insert. */
60
+ /** A raw-SQL column DEFAULT (emitted unquoted in the DDL), produced by the `expr`
61
+ * helpers below. Distinct from a literal default so `defaultTo` can render
62
+ * `DEFAULT datetime('now')` rather than the quoted string `DEFAULT 'datetime(...)'`. */
63
+ export class ExprDefault {
64
+ sql;
65
+ constructor(sql) {
66
+ this.sql = sql;
67
+ }
68
+ }
69
+ /** SQL-expression defaults for `defaultTo(field, expr.now())`. `now()` is the current
70
+ * UTC timestamp as TEXT (`'YYYY-MM-DD HH:MM:SS'`, like `CURRENT_TIMESTAMP`) — pair it
71
+ * with `t.text()`. `raw(sql)` is an escape hatch for any other SQLite default expression. */
72
+ export const expr = {
73
+ now: () => new ExprDefault("datetime('now')"),
74
+ raw: (sql) => new ExprDefault(sql),
75
+ };
61
76
  export function defaultTo(field, value) {
62
- return { ...field, default: value };
77
+ return value instanceof ExprDefault ? { ...field, defaultExpr: value.sql } : { ...field, default: value };
63
78
  }
64
79
  /** Mark a column as the PRIMARY KEY (implies NOT NULL). Composes with any builder,
65
80
  * e.g. `id: primaryKey(generated(t.uuid()))` or `code: primaryKey(t.text())`. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/server",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "description": "pramen server runtime — schema, ACL, ORM, live queries, files, and the createPramen(app) factory for Cloudflare Workers + Durable Objects.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/index.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  // and codegen load an app.ts for its schema without dragging in the DO runtime.
9
9
 
10
10
  // --- schema authoring ---
11
- export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, defaultTo, primaryKey, generated } from "./sdk/schema";
11
+ export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, defaultTo, primaryKey, generated, expr, ExprDefault } from "./sdk/schema";
12
12
  export { isValidUuid } from "./sdk/uuid";
13
13
  export type {
14
14
  DefaultValue,
@@ -22,10 +22,15 @@ function defaultLiteral(v: DefaultValue): string {
22
22
  return `'${v.replace(/'/g, "''")}'`;
23
23
  }
24
24
 
25
- /** The ` DEFAULT x` fragment for a column, or "" when it has no default. UNIQUE/
26
- * index are NOT inline — they're emitted as separate index statements so the same
27
- * code path serves both CREATE TABLE and ALTER TABLE ADD COLUMN. */
25
+ /** The ` DEFAULT x` fragment for a column, or "" when it has no default. A raw-SQL
26
+ * `defaultExpr` (e.g. `datetime('now')`) is emitted UNQUOTED; a literal `default` is
27
+ * quote-escaped. UNIQUE/index are NOT inline they're emitted as separate index
28
+ * statements so the same code path serves both CREATE TABLE and ALTER TABLE ADD COLUMN. */
28
29
  function defaultSql(f: FieldDef): string {
30
+ // A raw-SQL default is parenthesized: SQLite's column-DEFAULT grammar only takes a
31
+ // bare literal/keyword, so a function call (e.g. datetime('now')) must be wrapped —
32
+ // `DEFAULT (datetime('now'))`. Parens are harmless around a keyword too.
33
+ if (f.defaultExpr !== undefined) return ` DEFAULT (${f.defaultExpr})`;
29
34
  return f.default !== undefined ? ` DEFAULT ${defaultLiteral(f.default)}` : "";
30
35
  }
31
36
 
@@ -157,8 +157,17 @@ export async function migrate(driver: Driver, schema: SchemaDef, opts: MigrateOp
157
157
  continue;
158
158
  }
159
159
  // Pass 1 — additive: add any column the schema declares but the table lacks.
160
+ // SQLite forbids ALTER ADD COLUMN with a non-constant DEFAULT (e.g. expr.now()),
161
+ // so such a column is added via a table rebuild instead — which is still additive
162
+ // (no data loss): the rebuild's INSERT omits the new column, so SQLite applies its
163
+ // CREATE TABLE default, backfilling existing rows. Flagged here, done in Pass 2.
164
+ let needsAdditiveRebuild = false;
160
165
  for (const [name, field] of Object.entries(def.fields)) {
161
166
  if (existing.has(name)) continue;
167
+ if ((field as FieldDef).defaultExpr !== undefined) {
168
+ needsAdditiveRebuild = true;
169
+ continue;
170
+ }
162
171
  await driver.exec(`ALTER TABLE ${ident(table)} ADD COLUMN ${addColumnSql(name, field as FieldDef)}`, []);
163
172
  added.push(`${table}.${name}`);
164
173
  }
@@ -176,13 +185,16 @@ export async function migrate(driver: Driver, schema: SchemaDef, opts: MigrateOp
176
185
  const needsTypeChange = Object.entries(def.fields).some(
177
186
  ([n, f]) => live.has(n) && live.get(n) !== sqlType(f as FieldDef),
178
187
  );
179
- if (needsDrop || needsTypeChange || renamedSources.size > 0) {
180
- if (allowDestructive) {
181
- await rebuildTable(driver, table, def, live);
182
- rebuilt.push(table);
183
- } else {
184
- skipped.push(`rebuild ${table} (drop/type-change/rename)`);
185
- }
188
+ const destructive = needsDrop || needsTypeChange || renamedSources.size > 0;
189
+ if (destructive && !allowDestructive) {
190
+ // The destructive part is gated off — skip the whole rebuild (any pending
191
+ // expr-default column waits until destructive migrations are allowed).
192
+ skipped.push(`rebuild ${table} (drop/type-change/rename)`);
193
+ } else if (destructive || needsAdditiveRebuild) {
194
+ // An additive-only rebuild (just an expr-default column) needs no permission —
195
+ // it loses no data.
196
+ await rebuildTable(driver, table, def, live);
197
+ rebuilt.push(table);
186
198
  }
187
199
  }
188
200
 
package/src/sdk/infer.ts CHANGED
@@ -113,7 +113,8 @@ export type WhereClause<S extends SchemaDef, T extends keyof S, D extends number
113
113
  export type InferUpdate<F extends EntityFields> = Partial<{ [K in keyof F]: FieldTsType<F[K]> | null }>;
114
114
 
115
115
  // Insert: a NOT NULL column is required unless it's auto-generated (autoIncrement,
116
- // or a `generated()` uuid the runtime mints) or has a DEFAULT (the DB fills it);
116
+ // or a `generated()` uuid the runtime mints) or has a DEFAULT a literal (`default`)
117
+ // or a SQL expression (`defaultExpr`, e.g. expr.now()), both filled by the DB;
117
118
  // everything else is optional.
118
119
  type RequiredInsertKeys<F extends EntityFields> = {
119
120
  [K in keyof F]: IsNotNull<F[K]> extends true
@@ -123,7 +124,9 @@ type RequiredInsertKeys<F extends EntityFields> = {
123
124
  ? never
124
125
  : F[K] extends { default: DefaultValue }
125
126
  ? never
126
- : K
127
+ : F[K] extends { defaultExpr: string }
128
+ ? never
129
+ : K
127
130
  : never;
128
131
  }[keyof F];
129
132
  type OptionalInsertKeys<F extends EntityFields> = Exclude<keyof F, RequiredInsertKeys<F>>;
package/src/sdk/schema.ts CHANGED
@@ -31,6 +31,10 @@ export interface FieldDef {
31
31
  readonly generated?: boolean;
32
32
  /** A column DEFAULT (a literal). Makes the column optional on insert. */
33
33
  readonly default?: DefaultValue;
34
+ /** A column DEFAULT that is raw SQL, emitted UNQUOTED (e.g. `datetime('now')`) —
35
+ * set by `defaultTo(field, expr.now())`/`expr.raw(...)`. Distinct from `default`
36
+ * (a quoted literal). Makes the column optional on insert. */
37
+ readonly defaultExpr?: string;
34
38
  /** Migration hint: this column was previously named X. On boot the migrator
35
39
  * rebuilds the table, copying data from the old column. A diff cannot tell a
36
40
  * rename from a drop+add, so the rename must be declared explicitly. */
@@ -130,9 +134,28 @@ export function indexed<F extends FieldDef>(field: F): F & { readonly index: tru
130
134
  return { ...field, index: true };
131
135
  }
132
136
 
133
- /** Give the column a DEFAULT (a literal) also makes it optional on insert. */
134
- export function defaultTo<F extends FieldDef, D extends DefaultValue>(field: F, value: D): F & { readonly default: D } {
135
- return { ...field, default: value };
137
+ /** A raw-SQL column DEFAULT (emitted unquoted in the DDL), produced by the `expr`
138
+ * helpers below. Distinct from a literal default so `defaultTo` can render
139
+ * `DEFAULT datetime('now')` rather than the quoted string `DEFAULT 'datetime(...)'`. */
140
+ export class ExprDefault {
141
+ constructor(readonly sql: string) {}
142
+ }
143
+
144
+ /** SQL-expression defaults for `defaultTo(field, expr.now())`. `now()` is the current
145
+ * UTC timestamp as TEXT (`'YYYY-MM-DD HH:MM:SS'`, like `CURRENT_TIMESTAMP`) — pair it
146
+ * with `t.text()`. `raw(sql)` is an escape hatch for any other SQLite default expression. */
147
+ export const expr = {
148
+ now: (): ExprDefault => new ExprDefault("datetime('now')"),
149
+ raw: (sql: string): ExprDefault => new ExprDefault(sql),
150
+ };
151
+
152
+ /** Give the column a DEFAULT — also makes it optional on insert. Pass a literal
153
+ * (rendered as a quoted SQL literal) or an `expr.*()` value (raw SQL, unquoted),
154
+ * e.g. `defaultTo(t.text(), "pending")` or `defaultTo(t.text(), expr.now())`. */
155
+ export function defaultTo<F extends FieldDef>(field: F, value: ExprDefault): F & { readonly defaultExpr: string };
156
+ export function defaultTo<F extends FieldDef, D extends DefaultValue>(field: F, value: D): F & { readonly default: D };
157
+ export function defaultTo<F extends FieldDef>(field: F, value: DefaultValue | ExprDefault): FieldDef {
158
+ return value instanceof ExprDefault ? { ...field, defaultExpr: value.sql } : { ...field, default: value };
136
159
  }
137
160
 
138
161
  /** Mark a column as the PRIMARY KEY (implies NOT NULL). Composes with any builder,