import type { Database } from "../database.js"; import type { CustomBlockHandle } from "../custom-block.js"; import { APPS_WORKSPACE_RESOURCE_ID, type DatabaseIntent, type DatabasePageLayout, type DataSourceDefinition, type NotionAsCodeIcon, type NotionAsCodeProperty, type NotionAsCodeSchema, type Parent, type ResourceId, } from "./intents.js"; import { createPage, type ChildPageArgs, type PageHandle } from "./page.js"; import { isMetadataRecording, recordIntent } from "./recorder.js"; import { assertParent, assertUserResourceId } from "./resource.js"; import { createAppsDatabase, type AppsSchemaForSchema } from "./schema.js"; import type { ViewSchema } from "./views.js"; /** Reuse serialized database intent metadata, excluding identity, parent, and sources. */ type DatabaseMetadata = Omit; type NonEmptyViewSchemas = readonly [ViewSchema, ...ViewSchema[]]; /** A keyed data-source declaration; the containing map key supplies the serialized source name. */ export type DataSourceArgs = { resourceId: ResourceId; name?: never; description?: string; schema: Schema; icon?: NotionAsCodeIcon; pageLayout?: DatabasePageLayout; }; /** Data sources keyed by the names used to access their handles. */ export type DataSourceMap = Record; /** * Single-source inputs require a name for both database and source. `datasources?: never` * keeps this form exclusive even when callers pass a predeclared object. */ type SingleSourceDatabaseOptions = Omit< DatabaseMetadata, "name" > & { dataSourceResourceId: ResourceId; name: string; schema: Schema; pageLayout?: DatabasePageLayout; datasources?: never; }; /** Top-level single-source inputs may choose a parent; page.addDatabase/teamspace.addDatabase bind it instead. */ export type SingleSourceDatabaseArgs = SingleSourceDatabaseOptions & { parent?: Parent; }; /** * Multi-source inputs preserve source-map keys. The NoInfer guard makes the * emptiness check inspect the inferred map instead of widening it to the broad * DataSourceMap constraint. */ type MultiSourceDatabaseOptions = DatabaseMetadata & { datasources: DataSources; dataSourceResourceId?: never; schema?: never; pageLayout?: never; } & (keyof NoInfer extends never ? { views: NonEmptyViewSchemas } : { views?: readonly ViewSchema[] }); /** Top-level multi-source inputs may choose a parent; page.addDatabase/teamspace.addDatabase bind it instead. */ export type MultiSourceDatabaseArgs = MultiSourceDatabaseOptions & { parent?: Parent; }; /** Linked-only databases use a nonempty typed view tuple and no authored source map. */ type ViewOnlyDatabaseOptions = DatabaseMetadata & { datasources?: never; dataSourceResourceId?: never; schema?: never; pageLayout?: never; views: NonEmptyViewSchemas; }; type ViewOnlyDatabaseArgs = ViewOnlyDatabaseOptions & { parent?: Parent; }; /** Remove parent because page.addDatabase/teamspace.addDatabase supplies it. */ export type ChildSingleSourceDatabaseArgs = Omit, "parent">; /** Remove parent because page.addDatabase/teamspace.addDatabase supplies it. */ export type ChildMultiSourceDatabaseArgs = Omit< MultiSourceDatabaseArgs, "parent" >; /** Input for declaring a database and its data sources. */ export type DatabaseArgs< Schema extends NotionAsCodeSchema = NotionAsCodeSchema, DataSources extends DataSourceMap = DataSourceMap, > = SingleSourceDatabaseArgs | MultiSourceDatabaseArgs | ViewOnlyDatabaseArgs; /** * Child inputs omit parent from each union member separately. `page.addDatabase` * and `teamspace.addDatabase` use this shape for their shared child factory. */ export type ChildDatabaseArgs< Schema extends NotionAsCodeSchema = NotionAsCodeSchema, DataSources extends DataSourceMap = DataSourceMap, > = | ChildSingleSourceDatabaseArgs | ChildMultiSourceDatabaseArgs | Omit; export type PropertyHandle< Property extends NotionAsCodeSchema[string] = NotionAsCodeSchema[string], > = { readonly resourceType: "property"; readonly resourceId: Property["resourceId"]; readonly dataSourceResourceId: string; readonly name: string; readonly propertyType: Property["type"]; }; export type DataSourceProperties = { readonly [Name in keyof Schema]: PropertyHandle; }; /** A source handle retains its keyed authoring schema for sync inference. */ export type DataSourceHandle = { readonly resourceType: "dataSource"; readonly resourceId: string; readonly name: string; readonly description?: string; readonly schema: Schema; readonly properties: DataSourceProperties; readonly database: Database>; addPage: (args: ChildPageArgs) => PageHandle; }; type DatabaseHandleBase = { readonly resourceType: "database"; readonly resourceId: string; addView: (view: ViewSchema) => void; addCustomView: (view: AddCustomViewArgs) => void; }; export type AddCustomViewArgs = { resourceId: ResourceId; name?: string; icon?: Extract; customBlock: CustomBlockHandle; dataSource?: DataSourceHandle; }; /** Map each authoring source key to a handle backed by that source's own schema. */ export type DatabaseHandle = DatabaseHandleBase & { readonly datasources: { readonly [DataSourceName in keyof DataSources]: DataSourceHandle< DataSources[DataSourceName]["schema"] >; }; }; /** A database whose sole data source is available without a key lookup. */ export type SingleSourceDatabaseHandle = DatabaseHandleBase & { readonly dataSource: DataSourceHandle; }; /** * Overloaded callable used by page.addDatabase/teamspace.addDatabase. Each form * returns its matching handle while preserving literal source and schema inference. */ export type ChildDatabaseFactory = { ( databaseResourceId: ResourceId, args: ChildSingleSourceDatabaseArgs, ): SingleSourceDatabaseHandle; ( databaseResourceId: ResourceId, args: ChildMultiSourceDatabaseArgs, ): DatabaseHandle; ( databaseResourceId: ResourceId, args: Omit, ): DatabaseHandle<{}>; }; /** Declare a database, defaulting to the Apps workspace. */ export function database( databaseResourceId: ResourceId, args: SingleSourceDatabaseArgs, ): SingleSourceDatabaseHandle; export function database( databaseResourceId: ResourceId, args: MultiSourceDatabaseArgs, ): DatabaseHandle; export function database( databaseResourceId: ResourceId, args: ViewOnlyDatabaseArgs, ): DatabaseHandle<{}>; export function database( databaseResourceId: ResourceId, args: DatabaseArgs, ): DatabaseHandle | SingleSourceDatabaseHandle { if (typeof args !== "object" || args === null || Array.isArray(args)) { throw new Error("Notion-as-Code database arguments must be an object"); } const parentValue = args.parent; if (parentValue !== undefined) { assertParent(parentValue); } const parent = parentValue ?? { type: "resourceId" as const, resourceId: APPS_WORKSPACE_RESOURCE_ID, }; return buildDatabaseHandle(databaseResourceId, args, parent); } /** Bind a parent for page.addDatabase/teamspace.addDatabase. */ export function createChildDatabase(parent: Parent): ChildDatabaseFactory { assertParent(parent); function addDatabase( databaseResourceId: ResourceId, args: ChildSingleSourceDatabaseArgs, ): SingleSourceDatabaseHandle; function addDatabase( databaseResourceId: ResourceId, args: ChildMultiSourceDatabaseArgs, ): DatabaseHandle; function addDatabase( databaseResourceId: ResourceId, args: Omit, ): DatabaseHandle<{}>; function addDatabase( databaseResourceId: ResourceId, args: ChildDatabaseArgs, ): DatabaseHandle | SingleSourceDatabaseHandle { return buildDatabaseHandle(databaseResourceId, args, parent); } return addDatabase; } /** * Internal bridge pairing an authoring key/schema with its serialized definition * during database recording and handle creation; never serialized itself. */ type SourceEntry = { key: string; schema: NotionAsCodeSchema; definition: DataSourceDefinition; }; /** * Build a database handle from one of the authoring forms. Form checks happen * before normalization or recording so mixed and malformed declarations are atomic. */ function buildDatabaseHandle( databaseResourceId: ResourceId, args: DatabaseArgs, parent: Parent, ): DatabaseHandle | SingleSourceDatabaseHandle { if (typeof args !== "object" || args === null || Array.isArray(args)) { throw new Error("Notion-as-Code database arguments must be an object"); } if (typeof databaseResourceId !== "string") { throw new Error("Notion-as-Code database resourceId must be a string"); } assertUserResourceId(databaseResourceId); const hasSchema = Object.prototype.hasOwnProperty.call(args, "schema"); const hasDataSources = Object.prototype.hasOwnProperty.call(args, "datasources"); if (hasSchema && hasDataSources) { throw new Error("Specify either schema or datasources, not both"); } if (args.schema !== undefined) { const source: DataSourceDefinition = { resourceId: args.dataSourceResourceId, name: args.name, ...(args.description === undefined ? {} : { description: args.description }), properties: normalizeProperties(args.schema), ...(args.pageLayout === undefined ? {} : { pageLayout: args.pageLayout }), }; const handle = recordDatabase(databaseResourceId, args, parent, [ { key: "dataSource", schema: args.schema, definition: source }, ]); const dataSource = handle.datasources.dataSource; if (dataSource === undefined) { throw new Error(`Database "${databaseResourceId}" is missing its data source`); } return { resourceType: "database", resourceId: databaseResourceId, dataSource, addView: handle.addView, addCustomView: handle.addCustomView, }; } if (args.datasources !== undefined) { if ( typeof args.datasources !== "object" || args.datasources === null || Array.isArray(args.datasources) ) { throw new Error("Notion-as-Code datasources must be an object"); } const sourceEntries = Object.entries(args.datasources).map(([key, sourceArgs]) => normalizeDataSource(key, sourceArgs), ); return recordDatabase(databaseResourceId, args, parent, sourceEntries); } return recordDatabase(databaseResourceId, args, parent, []); } /** Strip authoring-only fields from database arguments before serializing metadata. */ function normalizeDatabaseMetadata(args: DatabaseArgs): DatabaseMetadata { const { parent: _parent, schema: _schema, datasources: _datasources, dataSourceResourceId: _dataSourceResourceId, pageLayout: _pageLayout, ...metadata } = args; return metadata; } /** Normalize a keyed declaration into its serialized definition while retaining key/schema for handles. */ function normalizeDataSource(key: string, sourceArgs: DataSourceArgs): SourceEntry { if (typeof sourceArgs !== "object" || sourceArgs === null || Array.isArray(sourceArgs)) { throw new Error(`Data source "${key}" must be an object`); } const { schema, ...sourceMetadata } = sourceArgs; return { key, schema, definition: { ...sourceMetadata, name: key, properties: normalizeProperties(schema), }, }; } /** * Normalize keyed property configurations to the serialized property array. * Property names always come from schema keys; authoring name fields are ignored. */ function normalizeProperties(schema: NotionAsCodeSchema): NotionAsCodeProperty[] { if (typeof schema !== "object" || schema === null || Array.isArray(schema)) { throw new Error("Notion-as-Code schema must be an object"); } const properties: NotionAsCodeProperty[] = []; for (const [key, propertyConfig] of Object.entries(schema)) { if ( typeof propertyConfig !== "object" || propertyConfig === null || Array.isArray(propertyConfig) ) { throw new Error(`Property "${key}" must be an object`); } if (typeof propertyConfig.resourceId !== "string") { throw new Error(`Property "${key}" must specify a resourceId`); } properties.push({ ...propertyConfig, name: key, }); } return properties; } /** Validate the views shape and require at least one data source or view. */ function assertSourceOrView(sourceEntries: readonly SourceEntry[], views: unknown): void { if (views !== undefined && !Array.isArray(views)) { throw new Error("Notion-as-Code database views must be an array"); } if (sourceEntries.length === 0 && (!Array.isArray(views) || views.length === 0)) { throw new Error("Notion-as-Code database must define a data source or view"); } } /** Record a normalized database intent and create handles for its data sources. */ function recordDatabase( databaseResourceId: ResourceId, args: DatabaseArgs, parent: Parent, sourceEntries: readonly SourceEntry[], ): DatabaseHandle { const metadata = normalizeDatabaseMetadata(args); assertSourceOrView(sourceEntries, metadata.views); const intent: DatabaseIntent = { ...metadata, resourceId: databaseResourceId, parent, dataSources: sourceEntries.map(({ definition }) => definition), }; return createDatabase(intent, sourceEntries); } /** Create a normalized database handle while preserving each source's authoring schema. */ function createDatabase( intent: DatabaseIntent, sourceEntries: readonly SourceEntry[], ): DatabaseHandle { assertUserResourceId(intent.resourceId); const dataSourceIds = new Set(); const handles: Record = Object.create(null); for (const { key, schema, definition } of sourceEntries) { if (typeof definition.resourceId !== "string") { throw new Error(`Data source "${key}" must specify a resourceId`); } assertUserResourceId(definition.resourceId); if (dataSourceIds.has(definition.resourceId)) { throw new Error( `Database "${intent.resourceId}" has duplicate data source "${definition.resourceId}"`, ); } dataSourceIds.add(definition.resourceId); handles[key] = createDataSourceHandle(definition, schema, intent.description); } recordIntent({ ...intent, type: "database" }); return { resourceType: "database", resourceId: intent.resourceId, datasources: handles, addView(view) { recordIntent({ type: "view", databaseResourceId: intent.resourceId, view }); }, addCustomView(view) { recordCustomView(intent.resourceId, view, handles); }, }; } function recordCustomView( databaseResourceId: ResourceId, view: AddCustomViewArgs, handles: Readonly>, ): void { assertUserResourceId(view.resourceId); const customBlockKey = view.customBlock.key; if (typeof customBlockKey !== "string" || customBlockKey.trim() === "") { if (!isMetadataRecording()) { return; } throw new Error( "addCustomView must be called from the custom block capability file so its key is available during build", ); } const dataSources = Object.values(handles); const boundDataSourceIds = new Set( Object.values(view.customBlock.config.defaultBindings ?? {}).map( (binding) => binding.dataSourceResourceId, ), ); let dataSourceResourceId: string; if (view.dataSource !== undefined) { if (!dataSources.includes(view.dataSource)) { throw new Error( `Custom view "${view.resourceId}" dataSource must belong to database "${databaseResourceId}"`, ); } if (!boundDataSourceIds.has(view.dataSource.resourceId)) { throw new Error( `Custom view "${view.resourceId}" dataSource must be bound by custom block "${customBlockKey}"`, ); } dataSourceResourceId = view.dataSource.resourceId; } else { const matchingSources = dataSources.filter((source) => boundDataSourceIds.has(source.resourceId), ); const matchingSource = matchingSources[0]; if (matchingSources.length !== 1 || matchingSource === undefined) { throw new Error( `Custom view "${view.resourceId}" must bind exactly one data source from database "${databaseResourceId}" or specify dataSource`, ); } dataSourceResourceId = matchingSource.resourceId; } recordIntent({ type: "view", databaseResourceId, view: { type: "custom", resourceId: view.resourceId, ...(view.name === undefined ? {} : { name: view.name }), ...(view.icon === undefined ? {} : { icon: view.icon }), dataSourceResourceId, customBlockKey, }, }); } /** Create a data-source handle backed by the Apps database used by syncs. */ function createDataSourceHandle( dataSource: DataSourceDefinition, schema: NotionAsCodeSchema, databaseDescription: string | undefined, ): DataSourceHandle { const propertyEntries: Array<[string, PropertyHandle]> = []; const propertyResourceIds = new Set(); const propertyNames = new Set(); for (const property of dataSource.properties) { assertUserResourceId(property.resourceId); if (propertyResourceIds.has(property.resourceId)) { throw new Error( `Data source "${dataSource.resourceId}" has duplicate property resourceId "${property.resourceId}"`, ); } if (propertyNames.has(property.name)) { throw new Error( `Data source "${dataSource.resourceId}" has duplicate property name "${property.name}"`, ); } propertyResourceIds.add(property.resourceId); propertyNames.add(property.name); propertyEntries.push([ property.name, { resourceType: "property", resourceId: property.resourceId, dataSourceResourceId: dataSource.resourceId, name: property.name, propertyType: property.type, }, ]); } const description = dataSource.description ?? databaseDescription; return { resourceType: "dataSource", resourceId: dataSource.resourceId, name: dataSource.name, ...(description === undefined ? {} : { description }), schema, properties: Object.fromEntries(propertyEntries), database: createAppsDatabase(dataSource.resourceId, dataSource), addPage(pageArgs) { return createPage(pageArgs, { type: "resourceId", resourceId: dataSource.resourceId }); }, }; }