import { describe, expect, expectTypeOf, it } from "vitest"; import { Builder } from "../builder.js"; import { customBlock } from "../custom-block.js"; import { ExecutionError } from "../error.js"; import { sync } from "../sync.js"; import { notion, type DataSourceHandle, type SingleSourceDatabaseHandle } from "./index.js"; import { APPS_WORKSPACE_RESOURCE_ID } from "./intents.js"; import { finishMetadataRecording, startMetadataRecording, withMetadataCapabilityKey, } from "./recorder.js"; describe("Notion-as-Code database declarations", () => { it("infers a custom view data source from the block's default bindings", async () => { const recorder = startMetadataRecording(); const tasks = notion.database("tasks-db", { dataSourceResourceId: "tasks-source", name: "Tasks", schema: { Name: { resourceId: "task-name", type: "title" } }, }); await withMetadataCapabilityKey("board", async () => { const board = customBlock({ path: "./blocks/board", dataSources: { tasks }, }); tasks.addCustomView({ resourceId: "tasks-board-view", name: "Board", customBlock: board, }); }); expect(finishMetadataRecording(recorder).intents.at(-1)).toEqual({ type: "view", databaseResourceId: "tasks-db", view: { type: "custom", resourceId: "tasks-board-view", name: "Board", dataSourceResourceId: "tasks-source", customBlockKey: "board", }, }); }); it("requires an explicit bound source when a block binds multiple database sources", async () => { const recorder = startMetadataRecording(); const tasks = notion.database("tasks-db", { name: "Tasks", datasources: { Active: { resourceId: "active-source", schema: { Name: { resourceId: "active-name", type: "title" } }, }, Archive: { resourceId: "archive-source", schema: { Name: { resourceId: "archive-name", type: "title" } }, }, }, }); const projects = notion.database("projects-db", { dataSourceResourceId: "projects-source", name: "Projects", schema: { Name: { resourceId: "project-name", type: "title" } }, }); await withMetadataCapabilityKey("board", async () => { const board = customBlock({ path: "./blocks/board", dataSources: { active: tasks.datasources.Active, archive: tasks.datasources.Archive, }, }); expect(() => tasks.addCustomView({ resourceId: "ambiguous-board-view", customBlock: board, }), ).toThrow("must bind exactly one data source"); tasks.addCustomView({ resourceId: "active-board-view", customBlock: board, dataSource: tasks.datasources.Active, }); expect(() => tasks.addCustomView({ resourceId: "foreign-board-view", customBlock: board, dataSource: projects.dataSource, }), ).toThrow('dataSource must belong to database "tasks-db"'); const activeOnlyBoard = customBlock({ path: "./blocks/active-board", dataSources: { active: tasks.datasources.Active }, }); expect(() => tasks.addCustomView({ resourceId: "unbound-board-view", customBlock: activeOnlyBoard, dataSource: tasks.datasources.Archive, }), ).toThrow('dataSource must be bound by custom block "board"'); }); expect(finishMetadataRecording(recorder).intents.at(-1)).toEqual( expect.objectContaining({ view: expect.objectContaining({ dataSourceResourceId: "active-source" }), }), ); }); it("records a single-source page layout on the data source", () => { const recorder = startMetadataRecording(); notion.database("layout-database", { dataSourceResourceId: "layout-source", name: "Tasks", schema: { Name: { type: "title", resourceId: "name" }, Estimate: { type: "number", resourceId: "estimate" }, }, pageLayout: { main: [ { type: "properties", properties: [{ property: "name" }] }, { type: "property", property: "estimate", style: "large" }, ], pinnedProperties: ["estimate"], }, }); expect(finishMetadataRecording(recorder).intents).toEqual([ expect.objectContaining({ resourceId: "layout-database", dataSources: [ expect.objectContaining({ resourceId: "layout-source", pageLayout: { main: [ { type: "properties", properties: [{ property: "name" }] }, { type: "property", property: "estimate", style: "large" }, ], pinnedProperties: ["estimate"], }, }), ], }), ]); }); it("converts the single-source form with keyed schemas and resolved property names", () => { const recorder = startMetadataRecording(); const schema = { Title: { type: "title", resourceId: "single-title-id" }, Estimate: { type: "number", resourceId: "single-estimate-id" }, State: { type: "status", resourceId: "single-state-id", options: { todo: [{ name: "Not started", color: "gray" }], inProgress: [{ name: "Doing", color: "blue", default: true }], complete: [{ name: "Done", color: "green" }], }, }, } as const; const database = notion.database("single-database-id", { dataSourceResourceId: "single-source-id", name: "Tasks", schema, }); const intents = finishMetadataRecording(recorder).intents; expectTypeOf(database).toExtend>(); expectTypeOf(database.dataSource).toExtend>(); expectTypeOf(database.dataSource.schema.Title.type).toEqualTypeOf<"title">(); expectTypeOf( database.dataSource.schema.Title.resourceId, ).toEqualTypeOf<"single-title-id">(); expect(database.dataSource.schema).toEqual(schema); expect(database.dataSource.name).toBe("Tasks"); const declaration = sync({ dataSource: database.dataSource, primaryKey: "Title", handler: async () => ({ changes: [] }), }); expect(declaration.config.database.key).toBe("single-source-id"); expect(declaration.config.primaryKeyProperty).toBe("Title"); const invalidSyncDeclarations = () => { sync({ dataSource: database.dataSource, // @ts-expect-error Number properties cannot be sync primary keys. primaryKey: "Estimate", handler: async () => ({ changes: [] }), }); sync({ dataSource: database.dataSource, // @ts-expect-error Unknown properties cannot be used as sync primary keys. primaryKey: "Unknown", handler: async () => ({ changes: [] }), }); }; void invalidSyncDeclarations; expect(intents).toEqual([ { type: "database", resourceId: "single-database-id", name: "Tasks", parent: { type: "resourceId", resourceId: APPS_WORKSPACE_RESOURCE_ID }, dataSources: [ { resourceId: "single-source-id", name: "Tasks", properties: [ { type: "title", resourceId: "single-title-id", name: "Title" }, { type: "number", resourceId: "single-estimate-id", name: "Estimate" }, { type: "status", resourceId: "single-state-id", name: "State", options: { todo: [{ name: "Not started", color: "gray" }], inProgress: [{ name: "Doing", color: "blue", default: true }], complete: [{ name: "Done", color: "green" }], }, }, ], }, ], }, ]); }); it("accepts and executes partial upsert properties for Notion-as-Code sources", async () => { const database = notion.database("partial-sync-database", { dataSourceResourceId: "partial-sync-source", name: "Meetings", schema: { Name: { resourceId: "partial-sync-name", type: "title" }, "Meeting ID": { resourceId: "partial-sync-id", type: "text" }, Other: { resourceId: "partial-sync-other", type: "text" }, "Meeting date": { resourceId: "partial-sync-date", type: "date" }, "Prep complete": { resourceId: "partial-sync-prep", type: "checkbox" }, }, }); const valid = sync({ dataSource: database.dataSource, primaryKey: "Meeting ID", handler: async (state: { cursor: number } | undefined) => ({ changes: [ { type: "upsert", key: "meeting-1", properties: { Name: Builder.title("One") }, }, { type: "upsert", key: "meeting-2", properties: { "Prep complete": Builder.checkbox(true) }, }, { type: "upsert", key: "meeting-3", properties: { Other: Builder.text("Three") }, }, { type: "delete", key: "meeting-4" }, ], nextState: { cursor: (state?.cursor ?? 0) + 1 }, }), }); const result = await valid.handler({ state: { cursor: 4 } }, { concreteOutput: true }); expect(result).toEqual({ changes: [ { type: "upsert", key: "meeting-1", properties: { Name: [["One"]], "Meeting ID": [["meeting-1"]], }, targetDatabaseKey: "partial-sync-source", }, { type: "upsert", key: "meeting-2", properties: { "Prep complete": [["Yes"]], "Meeting ID": [["meeting-2"]], }, targetDatabaseKey: "partial-sync-source", }, { type: "upsert", key: "meeting-3", properties: { Other: [["Three"]], "Meeting ID": [["meeting-3"]], }, targetDatabaseKey: "partial-sync-source", }, { type: "delete", key: "meeting-4", targetDatabaseKey: "partial-sync-source", }, ], hasMore: false, nextUserContext: { cursor: 5 }, nextPacerStates: {}, }); const invalidRuntimeUpsert = sync({ dataSource: database.dataSource, primaryKey: "Meeting ID", handler: async () => ({ changes: [ { type: "upsert", key: "meeting-empty", properties: {}, icon: Builder.notionIcon("warning"), cover: Builder.imageCover("https://example.com/empty-cover.png"), pageContentMarkdown: "# Empty properties", }, ], }), } as never); await expect( invalidRuntimeUpsert.handler(undefined, { concreteOutput: true }), ).rejects.toBeInstanceOf(ExecutionError); const invalidSyncDeclarations = () => { const finiteProperties = { Name: Builder.title("Prepared"), Unknown: Builder.text("unexpected"), }; const spreadProperties = { Unknown: Builder.text("unexpected from spread"), }; sync({ dataSource: database.dataSource, primaryKey: "Meeting ID", // @ts-expect-error Upsert properties must include a non-primary-key schema property. handler: async () => ({ changes: [ { type: "upsert", key: "invalid-empty", properties: {}, }, ], }), }); sync({ dataSource: database.dataSource, primaryKey: "Meeting ID", // @ts-expect-error Unknown property keys are rejected in inferred handler returns. handler: async () => ({ changes: [ { type: "upsert", key: "invalid-inline", properties: { Name: Builder.title("Known"), Unknown: Builder.text("unexpected"), }, }, ], }), }); sync({ dataSource: database.dataSource, primaryKey: "Meeting ID", // @ts-expect-error Finite property objects cannot contain unknown keys. handler: async () => ({ changes: [ { type: "upsert", key: "invalid-finite", properties: finiteProperties, }, ], }), }); sync({ dataSource: database.dataSource, primaryKey: "Meeting ID", // @ts-expect-error Spread property objects cannot contain unknown keys. handler: async () => ({ changes: [ { type: "upsert", key: "invalid-spread", properties: { Name: Builder.title("Known from spread"), ...spreadProperties, }, }, ], }), }); sync({ dataSource: database.dataSource, primaryKey: "Meeting ID", // @ts-expect-error Known properties must use their declared value types. handler: async () => ({ changes: [ { type: "upsert", key: "invalid-value", properties: { Name: Builder.people("person@example.com"), }, }, ], }), }); sync({ dataSource: database.dataSource, primaryKey: "Meeting ID", // @ts-expect-error The primary-key property is supplied from change.key. handler: async () => ({ changes: [ { type: "upsert", key: "invalid-primary-property", properties: { "Meeting ID": Builder.text("explicit"), }, }, ], }), }); sync({ dataSource: database.dataSource, // @ts-expect-error Unknown schema properties cannot be sync primary keys. primaryKey: "Unknown", handler: async () => ({ changes: [] }), }); }; void invalidSyncDeclarations; }); it("converts keyed multi-source definitions and discriminates source handles", () => { const recorder = startMetadataRecording(); const taskSchema = { Title: { type: "title", resourceId: "tasks-title-id" }, Estimate: { type: "text", resourceId: "tasks-estimate-id" }, } as const; const archiveSchema = { Title: { type: "title", resourceId: "archive-title-id" }, ArchiveOnly: { type: "text", resourceId: "archive-only-id" }, } as const; const database = notion.database("multi-database-id", { name: "Project DB", datasources: { Tasks: { resourceId: "tasks-source-id", schema: taskSchema, }, "Archived Tasks": { resourceId: "archive-source-id", schema: archiveSchema, }, }, }); const intents = finishMetadataRecording(recorder).intents; expectTypeOf(database.datasources.Tasks).toExtend>(); expectTypeOf(database.datasources.Tasks.schema.Title.type).toEqualTypeOf<"title">(); expectTypeOf( database.datasources.Tasks.schema.Estimate.resourceId, ).toEqualTypeOf<"tasks-estimate-id">(); expectTypeOf(database.datasources["Archived Tasks"]).toExtend< DataSourceHandle >(); expectTypeOf( database.datasources["Archived Tasks"].schema.ArchiveOnly.type, ).toEqualTypeOf<"text">(); expect(database.datasources.Tasks.schema).toEqual(taskSchema); expect(database.datasources["Archived Tasks"].schema).toEqual(archiveSchema); const archiveSync = sync({ dataSource: database.datasources["Archived Tasks"], primaryKey: "ArchiveOnly", handler: async () => ({ changes: [] }), }); expect(archiveSync.config.database.key).toBe("archive-source-id"); const invalidCrossSourceSync = () => { sync({ dataSource: database.datasources.Tasks, // @ts-expect-error A primary key from another source is not accepted. primaryKey: "ArchiveOnly", handler: async () => ({ changes: [] }), }); }; void invalidCrossSourceSync; expect(intents).toEqual([ { type: "database", resourceId: "multi-database-id", name: "Project DB", parent: { type: "resourceId", resourceId: APPS_WORKSPACE_RESOURCE_ID }, dataSources: [ { resourceId: "tasks-source-id", name: "Tasks", properties: [ { type: "title", resourceId: "tasks-title-id", name: "Title" }, { type: "text", resourceId: "tasks-estimate-id", name: "Estimate" }, ], }, { resourceId: "archive-source-id", name: "Archived Tasks", properties: [ { type: "title", resourceId: "archive-title-id", name: "Title" }, { type: "text", resourceId: "archive-only-id", name: "ArchiveOnly" }, ], }, ], }, ]); }); it("rejects data-source name overrides, including predeclared maps", () => { const predeclaredDatasources = { Tasks: { resourceId: "predeclared-source-id", name: "Legacy source name", schema: { Title: { type: "title", resourceId: "predeclared-title-id" }, }, }, } as const; const invalidDeclarations = () => { // @ts-expect-error Data-source names are supplied by map keys. notion.database("inline-data-source-name-override", { datasources: { Tasks: { resourceId: "inline-source-id", name: "Legacy source name", schema: { Title: { type: "title", resourceId: "inline-title-id" }, }, }, }, }); notion.database("predeclared-data-source-name-override", { // @ts-expect-error Predeclared maps cannot override keyed source names. datasources: predeclaredDatasources, }); }; void invalidDeclarations; }); it("normalizes keyed source and property names despite legacy overrides", () => { const recorder = startMetadataRecording(); notion.database("runtime-name-override", { name: "Runtime", datasources: { "Runtime Source": { resourceId: "runtime-source-id", name: "Legacy source name", schema: { "Runtime Title": { type: "title", resourceId: "runtime-title-id", name: "Legacy property name", }, }, }, }, } as never); const intents = finishMetadataRecording(recorder).intents; expect(intents).toEqual([ { type: "database", resourceId: "runtime-name-override", name: "Runtime", parent: { type: "resourceId", resourceId: APPS_WORKSPACE_RESOURCE_ID }, dataSources: [ { resourceId: "runtime-source-id", name: "Runtime Source", properties: [ { type: "title", resourceId: "runtime-title-id", name: "Runtime Title", }, ], }, ], }, ]); }); it("rejects property name overrides, including predeclared schemas", () => { const predeclaredSchema = { Name: { type: "title", resourceId: "predeclared-name-id", name: "Display name" }, } as const; const invalidDeclarations = () => { notion.database("inline-name-override", { dataSourceResourceId: "inline-name-source", name: "Invalid", schema: { // @ts-expect-error Property names are supplied by schema keys. Name: { type: "title", resourceId: "inline-name-id", name: "Display name" }, }, }); notion.database("predeclared-name-override", { dataSourceResourceId: "predeclared-name-source", name: "Invalid", // @ts-expect-error Predeclared schemas cannot override keyed property names. schema: predeclaredSchema, }); }; void invalidDeclarations; }); it("rejects mixed single and multi-source arguments before recording", () => { const recorder = startMetadataRecording(); const mixed = { dataSourceResourceId: "mixed-source-id", name: "Mixed", schema: { Name: { type: "title", resourceId: "mixed-title-id" }, }, datasources: { Other: { resourceId: "other-source-id", schema: { Name: { type: "title", resourceId: "other-title-id" }, }, }, }, } as const; const typedMixedDeclaration = () => { // @ts-expect-error Single and multi-source forms are mutually exclusive. notion.database("mixed-database-id", mixed); }; void typedMixedDeclaration; expect(() => notion.database("mixed-database-id", mixed as never)).toThrow(); expect(finishMetadataRecording(recorder).intents).toEqual([]); }); it("rejects known empty databases statically and at runtime", () => { const emptySources = {}; const invalidEmptyDeclarations = () => { // @ts-expect-error A predeclared empty source map needs a source or a view. notion.database("empty-predeclared-datasources", { datasources: emptySources }); // @ts-expect-error A multi-source database needs a source or a view. notion.database("empty-datasources", { datasources: {} }); // @ts-expect-error A database needs a source or a view. notion.database("empty-arguments", {}); // @ts-expect-error Empty sources and views are not a valid database. notion.database("empty-with-views", { datasources: {}, views: [] }); }; void invalidEmptyDeclarations; const dynamicDatasources: Record = {}; const dynamicViews: never[] = []; expect(() => notion.database("runtime-empty-datasources", { datasources: {} } as never), ).toThrow(); expect(() => notion.database("runtime-empty-arguments", {} as never)).toThrow(); expect(() => notion.database("runtime-empty-with-views", { datasources: {}, views: [] } as never), ).toThrow(); expect(() => notion.database("runtime-empty-dynamic", { datasources: dynamicDatasources, views: dynamicViews, } as never), ).toThrow(); }); it("allows linked view-only databases with omitted or empty datasources", () => { const recorder = startMetadataRecording(); const linkedView = { resourceId: "linked-view", type: "table", dataSourceResourceId: "external-source", } as const; const linkedWithEmptyDatasources = notion.database("linked-empty-database", { datasources: {}, views: [linkedView], }); const linkedWithoutDatasources = notion.database("linked-omitted-database", { views: [ { resourceId: "linked-omitted-view", type: "table", dataSourceResourceId: "another-external-source", }, ], }); const invalidViewOnlyHandleAccess = () => { // @ts-expect-error View-only databases have no statically known data sources. void linkedWithEmptyDatasources.datasources.Missing; // @ts-expect-error View-only databases have no statically known data sources. void linkedWithoutDatasources.datasources.Missing; }; void invalidViewOnlyHandleAccess; const intents = finishMetadataRecording(recorder).intents; expect(intents).toEqual([ { type: "database", resourceId: "linked-empty-database", parent: { type: "resourceId", resourceId: APPS_WORKSPACE_RESOURCE_ID }, dataSources: [], views: [linkedView], }, { type: "database", resourceId: "linked-omitted-database", parent: { type: "resourceId", resourceId: APPS_WORKSPACE_RESOURCE_ID }, dataSources: [], views: [ { resourceId: "linked-omitted-view", type: "table", dataSourceResourceId: "another-external-source", }, ], }, ]); }); it("preserves source metadata, false flags, and omits authoring fields from JSON", () => { const recorder = startMetadataRecording(); notion.database("metadata-database", { dataSourceResourceId: "metadata-source", name: "Metadata", description: "Metadata source", schema: { Name: { type: "title", resourceId: "metadata-name" }, }, views: [ { resourceId: "metadata-view", type: "table", dataSourceResourceId: "metadata-source", showPageIcon: false, }, ], hideDataSourceTitle: false, hideDatabaseTitleIfEmpty: false, }); const intents = finishMetadataRecording(recorder).intents; expect(intents).toHaveLength(1); const serialized = JSON.parse(JSON.stringify(intents[0])!); expect(serialized).toEqual({ type: "database", resourceId: "metadata-database", name: "Metadata", description: "Metadata source", parent: { type: "resourceId", resourceId: APPS_WORKSPACE_RESOURCE_ID }, dataSources: [ { resourceId: "metadata-source", name: "Metadata", description: "Metadata source", properties: [{ type: "title", resourceId: "metadata-name", name: "Name" }], }, ], views: [ { resourceId: "metadata-view", type: "table", dataSourceResourceId: "metadata-source", showPageIcon: false, }, ], hideDataSourceTitle: false, hideDatabaseTitleIfEmpty: false, }); expect(serialized).not.toHaveProperty("schema"); expect(serialized).not.toHaveProperty("dataSourceResourceId"); expect(serialized).not.toHaveProperty("datasources"); expect(serialized.description).toBe("Metadata source"); }); it("allows parenting a database to a reserved Apps resource ID", () => { const recorder = startMetadataRecording(); notion.database("meetings-db", { parent: { type: "resourceId", resourceId: "__notion_apps_app_md__" }, dataSourceResourceId: "meetings-source", name: "Meetings", schema: { Name: { resourceId: "meeting-name", type: "title" } }, }); expect(finishMetadataRecording(recorder).intents[0]).toEqual( expect.objectContaining({ resourceId: "meetings-db", parent: { type: "resourceId", resourceId: "__notion_apps_app_md__" }, }), ); }); it("rejects creating a database whose resource ID uses the reserved Apps prefix", () => { expect(() => notion.database("__notion_apps_meetings__", { dataSourceResourceId: "meetings-source", name: "Meetings", schema: { Name: { resourceId: "meeting-name", type: "title" } }, }), ).toThrow('Resource ID "__notion_apps_meetings__" uses the reserved Apps prefix'); }); it("requires the status property configuration in authoring types", () => { const invalidStatusDeclaration = () => notion.database("missing-status-options-database", { dataSourceResourceId: "missing-status-options-source", name: "Invalid", schema: { Name: { type: "title", resourceId: "missing-status-title" }, // @ts-expect-error Status properties require all three option groups. Status: { type: "status", resourceId: "missing-status-id" }, }, }); void invalidStatusDeclaration; }); });