diff --git a/src/migrations/type-migration.ts b/src/migrations/type-migration.ts index 5643ef0..c4b3692 100644 --- a/src/migrations/type-migration.ts +++ b/src/migrations/type-migration.ts @@ -221,7 +221,7 @@ function buildV03Type(oldType: Dict, options: { isTaskNotes: boolean }): Dict { statusMetadata.completed_values = cloneJsonLike(fieldDef.tn_completed_values); } if (fieldDef.generated !== undefined) { - addGeneratedLifecycle(lifecycle, fieldName, fieldDef.generated); + addGeneratedLifecycle(lifecycle, fieldName, fieldDef.generated, fieldDef.type); } } @@ -422,13 +422,16 @@ function convertField( return { schema, links }; } -function addGeneratedLifecycle(lifecycle: Dict, fieldName: string, strategy: unknown): void { +function addGeneratedLifecycle(lifecycle: Dict, fieldName: string, strategy: unknown, fieldType?: unknown): void { + // Date-typed fields take the date-only {today} provider so generated values + // stay valid against the field's own schema. + const nowProvider = fieldType === "date" ? { today: true } : { now: true }; if (strategy === "now") { - lifecycleSet(lifecycle, "on_create", fieldName, { now: true }); + lifecycleSet(lifecycle, "on_create", fieldName, nowProvider); return; } if (strategy === "now_on_write") { - lifecycleSet(lifecycle, "on_update", fieldName, { now: true }); + lifecycleSet(lifecycle, "on_update", fieldName, nowProvider); return; } if (strategy === "uuid") { diff --git a/src/operations/collection.ts b/src/operations/collection.ts index b57f21c..9d52ed0 100644 --- a/src/operations/collection.ts +++ b/src/operations/collection.ts @@ -1812,7 +1812,7 @@ fields: for (const [fieldName, fieldDef] of Object.entries(typeDef.fields)) { if (fieldDef.generated === "now_on_write") { - frontmatter[fieldName] = new Date().toISOString(); + frontmatter[fieldName] = generateNowValue(fieldDef); } } } @@ -4008,9 +4008,8 @@ fields: case "uuid": return crypto.randomUUID(); case "now": - return new Date().toISOString(); case "now_on_write": - return new Date().toISOString(); + return generateNowValue(fieldDef); case "sequence": // Handled inline in create() flow return undefined; @@ -4883,6 +4882,18 @@ function formatDateTimeLocal(date: Date): string { return `${y}-${m}-${d}T${hh}:${mm}:${ss}`; } +/** + * Generate the current timestamp for a `now`/`now_on_write` field, formatted + * to match the field's declared type so the generated value passes validation: + * `date` → YYYY-MM-DD, `time` → HH:MM:SS, anything else → full ISO datetime. + */ +function generateNowValue(fieldDef: FieldDefinition): string { + const iso = new Date().toISOString(); + if (fieldDef.type === "date") return iso.slice(0, 10); + if (fieldDef.type === "time") return iso.slice(11, 19); + return iso; +} + function slugify(str: string): string { return str .normalize("NFD") diff --git a/test/generated-date-fields.test.ts b/test/generated-date-fields.test.ts new file mode 100644 index 0000000..ccd1040 --- /dev/null +++ b/test/generated-date-fields.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { Collection } from "../src/operations/collection.js"; +import { migrateV02TypeToV03 } from "../src/migrations/type-migration.js"; + +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; +const DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/; +const TIME_RE = /^\d{2}:\d{2}:\d{2}$/; + +async function write(root: string, relativePath: string, content: string): Promise { + const fullPath = path.join(root, relativePath); + await fs.mkdir(path.dirname(fullPath), { recursive: true }); + await fs.writeFile(fullPath, content, "utf8"); +} + +async function setupCollection(): Promise<{ collection: Collection; root: string }> { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "mdbase-gen-date-")); + await write( + root, + "mdbase.yaml", + [ + 'spec_version: "0.2.0"', + "settings:", + " default_validation: error", + "", + ].join("\n"), + ); + await write( + root, + "_types/note.md", + [ + "---", + "name: note", + "fields:", + " title:", + " type: string", + " required: true", + " created:", + " type: date", + " generated: now", + " updated:", + " type: date", + " generated: now_on_write", + " touched_at:", + " type: datetime", + " generated: now_on_write", + " touched_time:", + " type: time", + " generated: now_on_write", + "---", + "", + ].join("\n"), + ); + const result = await Collection.open(root); + if (result.error || !result.collection) { + throw new Error(result.error?.message ?? "Failed to open collection"); + } + return { collection: result.collection, root }; +} + +describe("generated now/now_on_write on date-typed fields", () => { + it("create stamps date-typed generated fields with a plain date and passes validation", async () => { + const { collection } = await setupCollection(); + const result = await collection.create({ + path: "notes/a.md", + frontmatter: { type: "note", title: "Hello" }, + }); + + expect(result.error).toBeUndefined(); + expect(result.frontmatter?.created).toMatch(DATE_RE); + expect(result.frontmatter?.updated).toMatch(DATE_RE); + expect(result.frontmatter?.touched_at).toMatch(DATETIME_RE); + expect(result.frontmatter?.touched_time).toMatch(TIME_RE); + }); + + it("update stamps date-typed now_on_write fields with a plain date and passes validation", async () => { + const { collection, root } = await setupCollection(); + await write( + root, + "notes/b.md", + [ + "---", + "type: note", + "title: Existing", + "created: 2024-01-01", + "updated: 2024-01-01", + 'touched_at: "2024-01-01T00:00:00Z"', + 'touched_time: "00:00:00"', + "---", + "body", + "", + ].join("\n"), + ); + + const result = await collection.update({ + path: "notes/b.md", + fields: { title: "Renamed" }, + }); + + expect(result.error).toBeUndefined(); + expect(result.frontmatter?.title).toBe("Renamed"); + expect(result.frontmatter?.updated).toMatch(DATE_RE); + expect(result.frontmatter?.touched_at).toMatch(DATETIME_RE); + expect(result.frontmatter?.touched_time).toMatch(TIME_RE); + }); + + it("migrates v0.2 now/now_on_write on date fields to the {today} provider", () => { + const result = migrateV02TypeToV03({ + name: "note", + fields: { + title: { type: "string", required: true }, + created: { type: "date", generated: "now" }, + updated: { type: "date", generated: "now_on_write" }, + touched_at: { type: "datetime", generated: "now_on_write" }, + }, + }); + + expect(result.valid).toBe(true); + const lifecycle = (result.typeFile as Record).lifecycle; + expect(lifecycle.on_create.set.created).toEqual({ today: true }); + expect(lifecycle.on_update.set.updated).toEqual({ today: true }); + expect(lifecycle.on_update.set.touched_at).toEqual({ now: true }); + }); +});