Skip to content

Commit 71de7fb

Browse files
dmealingclaude
andcommitted
fix(migrate-ts): SQLite timestamp "now" defaults use the ISO form generated code writes
A field.timestamp defaulting to now (@autoset, @default "now") emitted DEFAULT CURRENT_TIMESTAMP, which stores 2026-09-26 14:08:05, while generated code writes 2026-09-26T14:08:05.053Z. A table mixing database-defaulted and application-written rows held two spellings that neither sort nor compare as the same instant. The default is now strftime('%Y-%m-%dT%H:%M:%fZ','now'), which produces exactly Date.toISOString()'s shape. The generated Drizzle schema carries the same default (goldens and the showcase regenerated). No churn for existing databases: columnDefaultsEqual treats CURRENT_TIMESTAMP and the ISO expression as the same default, so a CURRENT_TIMESTAMP column is not rebuilt for the spelling. A new integration test proves the re-diff is empty against such a table. The SQLite emitter also wraps a function-call default in parentheses, as SQLite requires; an authored default such as datetime('now') previously produced a CREATE TABLE that did not parse. Docs: the SQLite floor section now states 3.35 for tables and views, and the CHANGELOG covers both SQLite changes plus the #389 agent-context change. Plan items 6 and 7 of the 1.1 plan are marked done. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M1mRRB1K2WdYy82kntMQdx
1 parent 8bf0e2f commit 71de7fb

16 files changed

Lines changed: 150 additions & 22 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,34 @@ here.**
1010

1111
## [Unreleased]
1212

13+
### Changed
14+
15+
- **SQLite: projection views that use `origin.collect` now open on SQLite 3.35.** They used an
16+
in-aggregate `ORDER BY` (`json_group_array(x ORDER BY …)`), which needs 3.44, so a database
17+
holding one could not be opened by Ubuntu 22.04's SQLite 3.37 or Python 3.10's stdlib. A
18+
collect column is now built in two steps: the grouped query gathers each element with its
19+
sort keys, and an outer query sorts them. Element order and empty-set results are
20+
unchanged, and whole-object collects still return objects. The floor is now 3.35 for tables
21+
and views alike. **Upgrading:** the next `meta migrate` against a SQLite or D1 database with
22+
such a view replaces the view once.
23+
- **SQLite: a timestamp column that defaults to "now" uses the ISO UTC form generated code
24+
writes.** `@autoSet` and `@default: "now"` on a `field.timestamp` emit
25+
`DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))` (`2026-09-26T14:08:05.053Z`) instead of
26+
`DEFAULT CURRENT_TIMESTAMP` (`2026-09-26 14:08:05`), so database-defaulted rows and
27+
application-written rows share one spelling that sorts and compares correctly. The generated
28+
Drizzle schema carries the same default. **Upgrading:** existing `CURRENT_TIMESTAMP` columns
29+
are not rebuilt for this: migrate treats the two as the same default. Rows already stored keep
30+
their old spelling. Regenerate with `meta gen` to pick up the Drizzle default.
31+
- **SQLite: a function-call column default is wrapped in parentheses** when emitted, as SQLite
32+
requires (`DEFAULT (expr)`). Before, an authored expression default such as
33+
`datetime('now')` produced a `CREATE TABLE` that did not parse.
34+
- **Agent context: hand-built LLM prompts** (#389). The always-on context installed by
35+
`meta init` gains a rule: once a project declares any `template.prompt`, every LLM prompt is
36+
one, replies are read through a response model, and changing a hand-built prompt means
37+
migrating it first. The `metaobjects-prompts` skill now triggers on code that builds text for
38+
an LLM or parses its reply, and has a migration procedure; the `metaobjects-audit` prompt
39+
census covers every module. Refresh with `meta init --refresh-docs`.
40+
1341
## [1.0.9] — 2026-09-26
1442

1543
_npm `1.0.9` (all 14 `@metaobjectsdev/*` packages in lockstep), PyPI `1.0.9`, NuGet `1.0.9` and

‎docs/features/migrations-and-drift.md‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -344,10 +344,21 @@ emits for `sqlite` / `d1` is therefore held to a floor:
344344
| `ALTER TABLE … DROP COLUMN` | 3.35 | a column is dropped (`--allow drop-column`) |
345345
| `ALTER TABLE … RENAME COLUMN` | 3.25 | a declared column rename |
346346
| aggregate `FILTER (WHERE …)`, `NULLS LAST` | 3.30 | a projection view with `origin.*` scoping / ordering |
347-
| in-aggregate `ORDER BY` (`json_group_array(x ORDER BY …)`) | **3.44** | a projection view using `origin.collect` |
348-
349-
**The floor for tables and their constraints is SQLite 3.35** (Ubuntu 22.04 ships 3.37.2;
350-
D1's baseline is 3.44). A database whose views use `origin.collect` needs **3.44** to open.
347+
| `json_each`, `json_group_array` (JSON1) | 3.35 in practice (JSON1 built in since 3.38; earlier builds usually enable it) | a projection view using `origin.collect` |
348+
349+
**The floor is SQLite 3.35, for tables and views alike** (Ubuntu 22.04 ships 3.37.2; D1's
350+
baseline is 3.44). Up to 1.0.9 a view using `origin.collect` ordered its elements with an
351+
in-aggregate `ORDER BY` (`json_group_array(x ORDER BY …)`), which needs SQLite 3.44. A
352+
collect column is now built in two steps: the grouped query gathers each element together
353+
with its sort keys, and an outer query sorts them, which every SQLite from 3.35 runs. The
354+
next `meta migrate` against a database made before that change replaces such views once;
355+
the re-diff is empty afterwards.
356+
357+
A `field.timestamp` whose default is "now" (`@autoSet`, or `@default: "now"`) defaults to
358+
`strftime('%Y-%m-%dT%H:%M:%fZ','now')`, the same ISO UTC form generated code writes, rather
359+
than `CURRENT_TIMESTAMP`'s `YYYY-MM-DD HH:MM:SS`. A column that already defaults to
360+
`CURRENT_TIMESTAMP` is not rebuilt for the spelling: the two are treated as the same default,
361+
and the ISO one arrives the next time something else rebuilds that table.
351362

352363
Null-safe (in)equality in a derived CHECK (`validator.requiredWhen`, `validator.presentIff`)
353364
is spelled with SQLite's own `IS NOT` / `IS`, which every version parses. Up to 1.0.8 it was

‎docs/superpowers/specs/2026-09-26-1.1-core-and-generators-plan.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,11 @@ teach writing a generator first and ejecting a reference second (1.0.9).
5757
its payload. Invest there: template location in errors, a response-format placement that
5858
templates can reference, and extract recovery cases from real replies.
5959

60-
6. **SQLite floor for projection views.** Views that use `origin.collect` need SQLite 3.44, and
60+
6. **DONE (on `main`, unreleased): SQLite floor for projection views.** Views that use `origin.collect` need SQLite 3.44, and
6161
a database containing one will not open on older SQLite (1.0.9 documents the floor). Rework
6262
the view SQL onto constructs SQLite 3.35 supports, with the persistence corpus updated in the
6363
same change.
64-
7. **One timestamp spelling on SQLite.** A column's `DEFAULT CURRENT_TIMESTAMP` writes
64+
7. **DONE (on `main`, unreleased): One timestamp spelling on SQLite.** A column's `DEFAULT CURRENT_TIMESTAMP` writes
6565
`YYYY-MM-DD HH:MM:SS` while generated code writes ISO `…T…Z`. Default new columns to the ISO
6666
form and add a diff-equivalence rule so existing columns are not rebuilt.
6767

‎examples/showcase/generated/sql/init/up.sql‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@ CREATE TABLE "subscribers" (
33
"email" VARCHAR(320) NOT NULL,
44
"name" TEXT,
55
"status" TEXT NOT NULL,
6-
"created_at" TEXT DEFAULT CURRENT_TIMESTAMP,
6+
"created_at" TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
77
CONSTRAINT "subscribers_status_chk" CHECK ("status" IN ('active', 'paused', 'cancelled'))
88
);

‎examples/showcase/site-payload.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
},
5151
"sql-migration": {
5252
"lang": "sql",
53-
"inline": "<span class=\"keyword\">CREATE TABLE</span> &quot;subscribers&quot; (\n &quot;id&quot; <span class=\"keyword\">INTEGER</span> <span class=\"keyword\">PRIMARY KEY</span> AUTOINCREMENT <span class=\"keyword\">NOT NULL</span>,\n &quot;email&quot; <span class=\"keyword\">VARCHAR</span>(<span class=\"string\">320</span>) <span class=\"keyword\">NOT NULL</span>,\n &quot;name&quot; TEXT,\n &quot;status&quot; TEXT <span class=\"keyword\">NOT NULL</span>,\n &quot;created_at&quot; TEXT <span class=\"keyword\">DEFAULT</span> <span class=\"keyword\">CURRENT_TIMESTAMP</span>,\n <span class=\"keyword\">CONSTRAINT</span> &quot;subscribers_status_chk&quot; <span class=\"keyword\">CHECK</span> (&quot;status&quot; <span class=\"keyword\">IN</span> (<span class=\"string\">&#x27;active&#x27;</span>, <span class=\"string\">&#x27;paused&#x27;</span>, <span class=\"string\">&#x27;cancelled&#x27;</span>))\n);\n",
53+
"inline": "<span class=\"keyword\">CREATE TABLE</span> &quot;subscribers&quot; (\n &quot;id&quot; <span class=\"keyword\">INTEGER</span> <span class=\"keyword\">PRIMARY KEY</span> AUTOINCREMENT <span class=\"keyword\">NOT NULL</span>,\n &quot;email&quot; <span class=\"keyword\">VARCHAR</span>(<span class=\"string\">320</span>) <span class=\"keyword\">NOT NULL</span>,\n &quot;name&quot; TEXT,\n &quot;status&quot; TEXT <span class=\"keyword\">NOT NULL</span>,\n &quot;created_at&quot; TEXT <span class=\"keyword\">DEFAULT</span> (strftime(<span class=\"string\">&#x27;%Y-%m-%dT%H:%M:%fZ&#x27;</span>,<span class=\"string\">&#x27;now&#x27;</span>)),\n <span class=\"keyword\">CONSTRAINT</span> &quot;subscribers_status_chk&quot; <span class=\"keyword\">CHECK</span> (&quot;status&quot; <span class=\"keyword\">IN</span> (<span class=\"string\">&#x27;active&#x27;</span>, <span class=\"string\">&#x27;paused&#x27;</span>, <span class=\"string\">&#x27;cancelled&#x27;</span>))\n);\n",
5454
"full": null,
5555
"lineCount": null
5656
},

‎server/typescript/packages/codegen-ts/src/templates/drizzle-schema.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
GENERATION_INCREMENT, GENERATION_UUID,
1111
FIELD_ATTR_AUTO_SET,
1212
FIELD_ATTR_OBJECT_REF,
13+
FIELD_SUBTYPE_TIMESTAMP,
1314
} from "@metaobjectsdev/metadata";
1415
import { fieldDeclaringPackage, type RenderContext } from "../render-context.js";
1516
import { crossEntitySpecifier, valueObjectModuleSpecifier } from "../import-path.js";
@@ -484,6 +485,10 @@ function inlineObjectLiteral(obj: Record<string, unknown>): string {
484485
}
485486

486487
/** Render one column line (field name + Drizzle column expression). */
488+
/** SQLite "now" for a timestamp column, spelled like Date.toISOString(). Must stay
489+
* byte-identical to migrate-ts `SQLITE_ISO_NOW`, which owns the DDL (ADR-0015). */
490+
const SQLITE_ISO_NOW = "strftime('%Y-%m-%dT%H:%M:%fZ','now')";
491+
487492
function renderColumn(
488493
spec: ColumnSpec,
489494
// §A6 — the column's physical name as an EXPRESSION: either the `<Entity>Names` constant
@@ -597,7 +602,11 @@ function renderColumn(
597602
if (spec.defaultExpr.kind === "now") {
598603
if (ctx.dialect === "sqlite") {
599604
const sqlSym = imp("sql@drizzle-orm");
600-
sqlDefaultSegment = code`.default(${sqlSym}\`CURRENT_TIMESTAMP\`)`;
605+
// A timestamp column defaults to the ISO UTC form generated code writes, the same
606+
// expression `meta migrate` puts in the DDL (migrate-ts SQLITE_ISO_NOW).
607+
sqlDefaultSegment = field.subType === FIELD_SUBTYPE_TIMESTAMP
608+
? code`.default(${sqlSym}\`(${SQLITE_ISO_NOW})\`)`
609+
: code`.default(${sqlSym}\`CURRENT_TIMESTAMP\`)`;
601610
} else {
602611
modifiersStr += `.defaultNow()`;
603612
}

‎server/typescript/packages/codegen-ts/test/golden/__snapshots__/sqlite/Program.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ export const programs = sqliteTable("programs", {
1414
isPublished: integer("is_published", { mode: "boolean" })
1515
.notNull()
1616
.default(false),
17-
createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
17+
createdAt: text("created_at")
18+
.notNull()
19+
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`),
1820
});
1921
export type Program = InferSelectModel<typeof programs>;
2022
export type ProgramInsert = InferInsertModel<typeof programs>;

‎server/typescript/packages/codegen-ts/test/golden/__snapshots__/sqlite/Purchase.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ export const purchases = sqliteTable("purchases", {
2626
.notNull()
2727
.references((): AnySQLiteColumn => programs.id),
2828
amountCents: integer("amount_cents").notNull(),
29-
purchasedAt: text("purchased_at").notNull().default(sql`CURRENT_TIMESTAMP`),
29+
purchasedAt: text("purchased_at")
30+
.notNull()
31+
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`),
3032
});
3133
export const purchasesRelations = relations(purchases, ({ one }) => ({
3234
subscriber: one(subscribers, {

‎server/typescript/packages/codegen-ts/test/golden/__snapshots__/sqlite/Subscriber.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ export const subscribers = sqliteTable("subscribers", {
1313
subscribed: integer("subscribed", { mode: "boolean" })
1414
.notNull()
1515
.default(true),
16-
createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
16+
createdAt: text("created_at")
17+
.notNull()
18+
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`),
1719
});
1820
export type Subscriber = InferSelectModel<typeof subscribers>;
1921
export type SubscriberInsert = InferInsertModel<typeof subscribers>;

‎server/typescript/packages/codegen-ts/test/golden/__snapshots__/sqlite/WorkoutEvent.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ export const workoutEvents = sqliteTable("workout_events", {
2525
workoutId: integer("workout_id")
2626
.notNull()
2727
.references((): AnySQLiteColumn => workouts.id),
28-
completedAt: text("completed_at").notNull().default(sql`CURRENT_TIMESTAMP`),
28+
completedAt: text("completed_at")
29+
.notNull()
30+
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`),
2931
durationMinutes: integer("duration_minutes"),
3032
});
3133
export const workoutEventsRelations = relations(workoutEvents, ({ one }) => ({

0 commit comments

Comments
 (0)