Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions src/migrations/type-migration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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") {
Expand Down
17 changes: 14 additions & 3 deletions src/operations/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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")
Expand Down
126 changes: 126 additions & 0 deletions test/generated-date-fields.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<string, any>).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 });
});
});