import type { CapabilityContext } from "./context.js"; import { createCapabilityContext } from "./context.js"; import type { Database } from "./database.js"; import { ExecutionError, RateLimitError } from "./error.js"; import type { DataSourceHandle } from "./notion-as-code/database.js"; import type { NotionAsCodeSchema } from "./notion-as-code/intents.js"; import type { AppsSchemaForSchema } from "./notion-as-code/schema.js"; import { writeOutput } from "./output.js"; import { initPacerState, pacerStates, type PacerEntry } from "./pacer.js"; import { resolveRuntimeInput } from "./runtime-input.js"; import { parseSchedule } from "./schedule.js"; import type { DatabaseSchema, PropertyConfiguration, TextProperty, TitleProperty, } from "./schema.js"; import type { Cover, HandlerOptions, Icon, NormalizedSchedule, PeopleValue, PlaceValue, RelationValue, Schedule, TextValue, } from "./types.js"; type KeyProperty = TitleProperty | TextProperty; /** Names of title or rich-text properties in a database schema. */ export type KeyPropertyName = Extract< { [P in keyof S]: S[P] extends KeyProperty ? P : never; }[keyof S], string >; type PropertyValue = T extends { type: "people" } ? PeopleValue : T extends { type: "place" } ? PlaceValue : T extends { type: "relation" } ? RelationValue : TextValue; type SyncPropertyName = Extract; /** * Collect unknown property names across the whole inferred change union before checking it. * Otherwise a valid subset properties object could structurally hide an invalid sibling row. */ type SyncUnknownPropertyNames = Change extends { type: "upsert"; properties: infer Properties extends object; } ? Exclude : never; /** Require at least one key while keeping all other keys optional. */ type AtLeastOne = Partial & { [RequiredKey in keyof T]-?: Required>; }[keyof T]; type SyncUpsertProperties< S extends DatabaseSchema, PrimaryKey extends KeyPropertyName, > = AtLeastOne<{ [P in Exclude, PrimaryKey>]: PropertyValue; }>; type ExactSyncHandlerResult< AllowedPropertyNames extends string, Result extends { changes: readonly unknown[] }, > = Result & { changes: [SyncUnknownPropertyNames] extends [ never, ] ? Result["changes"] : never; }; export type SyncMode = "replace" | "incremental"; /** Upsert one or more known non-primary-key properties; the key is supplied separately. */ export type SyncChangeUpsert> = { type: "upsert"; key: string; properties: SyncUpsertProperties; upstreamUpdatedAt?: string; icon?: Icon; cover?: Cover; pageContentMarkdown?: string; }; export type SyncChangeDelete = { type: "delete"; key: string }; export type SyncChange> = | SyncChangeUpsert | SyncChangeDelete; export type SyncHandlerResult< S extends DatabaseSchema, PrimaryKey extends KeyPropertyName, State, > = { changes: SyncChange[]; hasMore?: boolean; nextState?: State; }; export type SyncConfiguration< S extends DatabaseSchema, PrimaryKey extends KeyPropertyName, State, Result extends SyncHandlerResult = SyncHandlerResult< S, PrimaryKey, State >, > = { database: Database; primaryKey: PrimaryKey; mode?: SyncMode; schedule?: Schedule; handler: ( state: State | undefined, context: CapabilityContext, ) => Promise< // NoInfer prevents handler output from widening the source schema or primary-key inference. SyncHandlerResult, NoInfer, State> & ExactSyncHandlerResult< Exclude>, NoInfer>, Result > >; }; /** Configuration for a sync backed by a Notion-as-Code data source. */ export type DataSourceSyncConfiguration< Schema extends NotionAsCodeSchema, PrimaryKey extends KeyPropertyName>, State, Result extends SyncHandlerResult, PrimaryKey, State> = SyncHandlerResult, PrimaryKey, State>, > = Omit, PrimaryKey, State, Result>, "database"> & { dataSource: DataSourceHandle; }; export type SyncRuntimeContext = { state?: unknown; userContext?: unknown; pacers?: Record; }; type SyncDefinitionConfig = { database: Database; primaryKeyProperty: string; mode?: SyncMode; schedule?: NormalizedSchedule; }; type SyncDatabaseConfiguration = | { database: Database } | { dataSource: { database: Database } }; type RuntimeSyncChange = | { type: "delete"; key: string } | { type: "upsert"; key: string; properties: Record }; type SyncDefinitionConfiguration< S extends DatabaseSchema, PrimaryKey extends KeyPropertyName, State, > = SyncDatabaseConfiguration & { primaryKey: PrimaryKey; mode?: SyncMode; schedule?: Schedule; handler: ( state: State | undefined, context: CapabilityContext, ) => Promise<{ changes: SyncChange[] | RuntimeSyncChange[]; hasMore?: boolean; nextState?: State; }>; }; /** Resolve a Notion-as-Code data source to the Apps database used by its sync. */ function resolveSyncDatabase(configuration: SyncDatabaseConfiguration): Database { return "dataSource" in configuration ? configuration.dataSource.database : configuration.database; } export type SyncRunResult = { changes: unknown[]; hasMore: boolean; nextUserContext?: unknown; nextPacerStates: Record; }; export type Sync< S extends DatabaseSchema = DatabaseSchema, PrimaryKey extends KeyPropertyName = KeyPropertyName, State = unknown, > = { _tag: "sync"; config: SyncDefinitionConfig; handler: (context?: SyncRuntimeContext, options?: HandlerOptions) => Promise; /** @internal Retains the authored change type for workflow sync operations. */ readonly __changeType?: SyncChange; /** @internal Retains the authored state type for workflow sync operations. */ readonly __stateType?: State; }; export type SyncChangeFor = Definition extends Sync ? SyncChange : never; export type SyncStateFor = Definition extends Sync ? State : never; const SYNC_CAPABILITY_KEYS = new WeakMap(); /** @internal Bind the build-derived capability key to a sync declaration. */ export function bindSyncCapabilityKey(syncDefinition: Sync, key: string): void { SYNC_CAPABILITY_KEYS.set(syncDefinition, key); } /** @internal Read the build-derived capability key for a sync declaration. */ export function getSyncCapabilityKey(syncDefinition: Sync): string { const key = SYNC_CAPABILITY_KEYS.get(syncDefinition); if (key === undefined) { throw new Error( "Sync capability key is unavailable. Pass a sync declaration discovered from src/syncs/.", ); } return key; } /** Create a database sync. Default-export it from a sync file. */ export function createSync< const S extends DatabaseSchema, const PrimaryKey extends KeyPropertyName, State = unknown, const Result extends SyncHandlerResult = SyncHandlerResult< S, PrimaryKey, State >, >(configuration: SyncConfiguration): Sync { return createSyncDefinition(configuration); } /** Create a sync backed by a Notion-as-Code data source. */ export function sync< const Schema extends NotionAsCodeSchema, const PrimaryKey extends KeyPropertyName>, State = unknown, const Result extends SyncHandlerResult, PrimaryKey, State> = SyncHandlerResult, PrimaryKey, State>, >( configuration: DataSourceSyncConfiguration, ): Sync, PrimaryKey, State> { return createSyncDefinition(configuration); } function createSyncDefinition< S extends DatabaseSchema, PrimaryKey extends KeyPropertyName, State, >(configuration: SyncDefinitionConfiguration): Sync { const schedule = configuration.schedule === undefined ? undefined : parseSchedule(configuration.schedule); const database = resolveSyncDatabase(configuration); const config: SyncDefinitionConfig = { database, primaryKeyProperty: configuration.primaryKey, ...(configuration.mode === undefined ? {} : { mode: configuration.mode }), ...(schedule === undefined ? {} : { schedule }), }; return { _tag: "sync", config, async handler(runtimeContext, options) { try { runtimeContext = await resolveRuntimeInput(runtimeContext); initPacerState(runtimeContext?.pacers); const state = (runtimeContext?.state ?? runtimeContext?.userContext) as | State | undefined; const handlerResult = await configuration.handler(state, createCapabilityContext()); const changes = handlerResult.changes.map((change) => { if (change.type === "delete") { return { ...change, targetDatabaseKey: database.key }; } let hasOwnEnumerableProperty = false; for (const propertyName in change.properties) { if (Object.hasOwn(change.properties, propertyName)) { hasOwnEnumerableProperty = true; break; } } if (!hasOwnEnumerableProperty) { throw new Error( "Sync upsert properties must include at least one property.", ); } return { ...change, properties: { ...change.properties, [configuration.primaryKey]: [[change.key]], }, targetDatabaseKey: database.key, }; }); const result: SyncRunResult = { changes, hasMore: handlerResult.hasMore ?? false, nextUserContext: handlerResult.nextState, nextPacerStates: pacerStates(), }; if (options?.concreteOutput) return result; writeOutput({ _tag: "success", value: result }); return result; } catch (cause) { if (cause instanceof RateLimitError) { if (!options?.concreteOutput) { writeOutput({ _tag: "error", error: { _tag: "rate_limit", name: cause.name, message: cause.message, retryAfter: cause.retryAfter, }, }); } throw cause; } const error = new ExecutionError(cause); if (!options?.concreteOutput) { writeOutput({ _tag: "error", error: { _tag: "generic", name: error.name, message: error.message }, }); } throw error; } }, }; }