@notionhq/apps 0.0.14 → 0.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/AGENTS.md +13 -0
  2. package/README.md +126 -27
  3. package/dist/cli/emit-manifest.d.ts.map +1 -1
  4. package/dist/cli/emit-manifest.js +1 -4
  5. package/dist/context.d.ts.map +1 -1
  6. package/dist/context.js +3 -0
  7. package/dist/context.test.d.ts +2 -0
  8. package/dist/context.test.d.ts.map +1 -0
  9. package/dist/custom-block.d.ts +1 -1
  10. package/dist/custom-block.d.ts.map +1 -1
  11. package/dist/custom-block.js +3 -3
  12. package/dist/index.d.ts +8 -0
  13. package/dist/index.d.ts.map +1 -0
  14. package/dist/index.js +16 -0
  15. package/dist/notion-as-code/database.d.ts +23 -1
  16. package/dist/notion-as-code/database.d.ts.map +1 -1
  17. package/dist/notion-as-code/database.js +27 -0
  18. package/dist/notion-as-code/index.d.ts +1 -1
  19. package/dist/notion-as-code/index.d.ts.map +1 -1
  20. package/dist/notion-as-code/page.d.ts +3 -3
  21. package/dist/notion-as-code/page.d.ts.map +1 -1
  22. package/dist/notion-as-code/page.js +2 -7
  23. package/dist/notion-as-code/teamspace.d.ts +3 -3
  24. package/dist/notion-as-code/teamspace.d.ts.map +1 -1
  25. package/dist/notion-as-code/teamspace.js +2 -7
  26. package/dist/sync.d.ts +2 -2
  27. package/dist/sync.d.ts.map +1 -1
  28. package/dist/sync.js +3 -3
  29. package/dist/workflow.d.ts +6 -6
  30. package/dist/workflow.d.ts.map +1 -1
  31. package/dist/workflow.js +2 -2
  32. package/docs/BUILD.md +5 -5
  33. package/docs/CONNECTIONS.md +5 -4
  34. package/package.json +7 -1
  35. package/skills/connections/SKILL.md +86 -0
  36. package/skills/custom-blocks/SKILL.md +45 -0
  37. package/skills/notion-as-code/SKILL.md +189 -0
  38. package/skills/sync/SKILL.md +98 -0
  39. package/skills/workflow/SKILL.md +123 -0
  40. package/src/cli/build.test.ts +97 -7
  41. package/src/cli/emit-manifest.ts +1 -5
  42. package/src/context.test.ts +26 -0
  43. package/src/context.ts +8 -0
  44. package/src/custom-block.test.ts +4 -4
  45. package/src/custom-block.ts +2 -2
  46. package/src/index.ts +7 -0
  47. package/src/notion-as-code/database.ts +72 -2
  48. package/src/notion-as-code/index.ts +4 -0
  49. package/src/notion-as-code/page.ts +3 -11
  50. package/src/notion-as-code/teamspace.ts +3 -11
  51. package/src/sync.ts +2 -2
  52. package/src/workflow-connections-types.test.ts +6 -6
  53. package/src/workflow-types.test.ts +2 -2
  54. package/src/workflow.test.ts +31 -31
  55. package/src/workflow.ts +7 -7
@@ -0,0 +1,123 @@
1
+ ---
2
+ name: workflow
3
+ description: Build or review Notion App workflows for typed triggers, durable replay-safe steps, and idempotent effects.
4
+ user-invocable: true
5
+ disable-model-invocation: true
6
+ ---
7
+
8
+ # Workflow
9
+
10
+ Use this skill to add or review a workflow in this template.
11
+
12
+ ## Build or change a workflow
13
+
14
+ 1. Read `AGENTS.md` and the existing files in `src/workflows/`.
15
+ 2. Inspect the installed workflow and trigger declarations.
16
+ 3. Choose the trigger, outcome, step boundaries, and required configuration.
17
+ 4. Create one camelCase file directly in `src/workflows/`.
18
+ 5. Default-export `workflow(...)` and use typed trigger creators.
19
+ 6. Put all non-deterministic work in awaited `context.step(...)` calls.
20
+ 7. Give each step a stable display name. For repeated steps, keep the name
21
+ constant and pass a stable, unique composite `key`, such as
22
+ `{ key: ["process-page", page.id] }`.
23
+ 8. Return JSON-safe values needed by later steps.
24
+ 9. Use the step `id` as an idempotency key when supported.
25
+ 10. Run `npm run check` and `npm run build`.
26
+
27
+ Each direct `src/workflows/*.ts` file must default-export `workflow(...)`.
28
+ The camelCase file name becomes its workflow key.
29
+ Keep workflow-specific helpers in `src/workflows/lib/` and shared helpers in
30
+ `src/lib/`; direct children of `src/workflows/` are discovered as workflows.
31
+
32
+ For provider triggers, follow [trigger connection binding](../connections/SKILL.md#bind-triggers-to-connections).
33
+ Use the typed trigger callback to check `connectionKey` against the declared
34
+ provider connections.
35
+
36
+ Use a step for every value that can change and every external effect: network
37
+ and Notion API calls, mutable state reads, timestamps, random values, generated
38
+ IDs, messages, creates, and updates. Keep deterministic transforms of the event
39
+ and completed step results outside a step.
40
+
41
+ Completed steps replay saved results. Do not rely on in-memory mutations inside
42
+ a step. Return the values required by later code. Keep calls in a stable order
43
+ and give every step a stable display name. The name is the replay key by
44
+ default. For a repeated step, do not interpolate an item ID or loop index into
45
+ the name; pass a stable composite key instead:
46
+
47
+ ```ts
48
+ await context.step("Process page", { key: ["process-page", page.id] }, async ({ id }) =>
49
+ processPage(page, { idempotencyKey: id }),
50
+ );
51
+ ```
52
+
53
+ Keys must be stable across retries and unique within one workflow run.
54
+
55
+ An external effect can succeed before its step result is saved. Pass the
56
+ callback `id` as an idempotency key when supported; otherwise use a stable
57
+ external ID, upsert, or duplicate check.
58
+
59
+ Do not write credentials. Add only environment variable names and safe
60
+ placeholders to `.env.example` when configuration is required. Return only
61
+ JSON-serializable step values, throw on failed requests and missing required
62
+ configuration, and do not log secrets or private payloads.
63
+
64
+ ## Resources created with the App
65
+
66
+ Use [Notion as Code](../notion-as-code/SKILL.md) for pages and databases that
67
+ should be created during deployment. Prefer it over equivalent manual setup
68
+ or API calls that create the App's resources. Use runtime API calls for dynamic
69
+ data changes, not as a substitute for supported Notion as Code setup.
70
+ Keep declarations at module scope in
71
+ an imported helper. For example, declare a guide page in `src/lib/resources.ts`:
72
+
73
+ ```ts
74
+ import { page } from "@notionhq/apps";
75
+
76
+ export const guide = page({
77
+ resourceId: "workflow-guide",
78
+ content: "# Workflow guide\nThis App runs a scheduled workflow.",
79
+ });
80
+ ```
81
+
82
+ Import the module from `src/workflows/sayHello.ts` so the build records it:
83
+
84
+ ```ts
85
+ import "../lib/resources";
86
+ import { workflow } from "@notionhq/apps";
87
+ import { triggers } from "@notionhq/apps/triggers";
88
+
89
+ export default workflow({
90
+ name: "Say Hello",
91
+ description: "Says hello on a recurring schedule.",
92
+ triggers: [triggers.scheduled()],
93
+ handler: async (_event, context) => {
94
+ await context.step("Say hello", () => {
95
+ console.log("Hello from your workflow!");
96
+ });
97
+ },
98
+ });
99
+ ```
100
+
101
+ The page is provisioned during deployment, not on every workflow run.
102
+ Database declarations follow the same import pattern; see the
103
+ [sync example](../sync/SKILL.md#choose-the-database-source) for using a Notion as Code
104
+ data source in a sync. Notion as Code resource IDs are declaration identities, not live
105
+ Notion UUIDs to pass to `context.notion`. Runtime API calls still belong in
106
+ durable steps and need actual resolved Notion IDs.
107
+
108
+ ## Review a workflow
109
+
110
+ Review every file in `src/workflows/` and the modules it calls. Report each
111
+ finding with its file, line, impact, and fix. Treat these as errors:
112
+
113
+ 1. A workflow is not a direct file or does not default-export `workflow`.
114
+ 2. Trigger-specific event fields are used without type narrowing.
115
+ 3. Non-deterministic work occurs outside an awaited step.
116
+ 4. Step order or names can change between retries.
117
+ 5. A step result is not JSON-serializable, or later code needs an in-memory mutation.
118
+ 6. A retry-sensitive write lacks an idempotency key or duplicate guard.
119
+ 7. External failures are ignored, or credentials are hard-coded or logged.
120
+ 8. An explicit trigger connection key is undeclared, belongs to the wrong
121
+ provider, or repeats the same trigger-type/key pair.
122
+
123
+ Run `npm run check` and `npm run build` when dependencies are installed.
@@ -2,9 +2,101 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
 
5
- import { afterEach, describe, expect, it } from "vitest";
5
+ import { afterEach, describe, expect, expectTypeOf, it } from "vitest";
6
6
 
7
7
  import { buildApp } from "./build.js";
8
+ import {
9
+ notion,
10
+ type DataSourceHandle,
11
+ type SingleSourceDatabaseHandle,
12
+ } from "../notion-as-code/index.js";
13
+ import { finishMetadataRecording, startMetadataRecording } from "../notion-as-code/recorder.js";
14
+
15
+ describe("Notion-as-Code database shorthand", () => {
16
+ it("keeps provisioning and sync identity equivalent to an explicit source", () => {
17
+ const recorder = startMetadataRecording();
18
+ const database = notion.database({
19
+ resourceId: "issues",
20
+ name: "Issues",
21
+ properties: [
22
+ { resourceId: "title", name: "Name", type: "title" },
23
+ { resourceId: "count", name: "Count", type: "number" },
24
+ ],
25
+ });
26
+ expectTypeOf(database).toExtend<
27
+ SingleSourceDatabaseHandle<typeof database.dataSource.schema>
28
+ >();
29
+ expectTypeOf(
30
+ database.dataSource.database.config.schema.Count.type,
31
+ ).toEqualTypeOf<"number">();
32
+ database.dataSource.addPage({ resourceId: "issue" });
33
+ const shorthand = finishMetadataRecording(recorder).intents;
34
+
35
+ const explicitRecorder = startMetadataRecording();
36
+ const explicit = notion.database({
37
+ resourceId: "issues",
38
+ name: "Issues",
39
+ dataSources: [
40
+ {
41
+ resourceId: "issues-source",
42
+ name: "Issues",
43
+ properties: database.dataSource.schema,
44
+ },
45
+ ],
46
+ });
47
+ explicit.dataSources["issues-source"].addPage({ resourceId: "issue" });
48
+ // @ts-expect-error Explicit databases do not guarantee a single source.
49
+ void explicit.dataSource;
50
+ expect(shorthand).toEqual(finishMetadataRecording(explicitRecorder).intents);
51
+ expect(database.dataSource.database.key).toBe("issues-source");
52
+ expect(shorthand[1]).toEqual({
53
+ type: "page",
54
+ resourceId: "issue",
55
+ parent: { type: "resourceId", resourceId: "issues-source" },
56
+ });
57
+ });
58
+
59
+ it("preserves parent binding and inferred source types through child helpers", () => {
60
+ const recorder = startMetadataRecording();
61
+ const page = notion.page({ resourceId: "page" });
62
+ const team = notion.teamspace({ resourceId: "team", name: "Team", accessLevel: "private" });
63
+ for (const parent of [page, team]) {
64
+ const database = parent.addDatabase({
65
+ resourceId: `${parent.resourceId}-db`,
66
+ name: "Child",
67
+ properties: [
68
+ { resourceId: `${parent.resourceId}-title`, name: "Title", type: "title" },
69
+ ],
70
+ });
71
+ expectTypeOf(database.dataSource).toExtend<
72
+ DataSourceHandle<typeof database.dataSource.schema>
73
+ >();
74
+ expectTypeOf(database.dataSource.schema[0].name).toEqualTypeOf<"Title">();
75
+ }
76
+ const intents = finishMetadataRecording(recorder).intents;
77
+ expect(
78
+ intents.filter((intent) => intent.type === "database").map((intent) => intent.parent),
79
+ ).toEqual([
80
+ { type: "resourceId", resourceId: "page" },
81
+ { type: "resourceId", resourceId: "team" },
82
+ ]);
83
+ });
84
+
85
+ it("rejects mixed forms before recording an intent", () => {
86
+ const recorder = startMetadataRecording();
87
+ const declareMixedDatabase = () => {
88
+ // @ts-expect-error The shorthand and explicit forms are mutually exclusive.
89
+ notion.database({
90
+ resourceId: "mixed",
91
+ name: "Mixed",
92
+ properties: [],
93
+ dataSources: [],
94
+ });
95
+ };
96
+ expect(declareMixedDatabase).toThrow();
97
+ expect(finishMetadataRecording(recorder).intents).toEqual([]);
98
+ });
99
+ });
8
100
 
9
101
  const FIXTURES = fileURLToPath(new URL("../../test/fixtures/", import.meta.url));
10
102
  const EXAMPLES = fileURLToPath(new URL("../../examples/", import.meta.url));
@@ -54,9 +146,7 @@ describe("buildApp", () => {
54
146
  },
55
147
  ]);
56
148
  const worker = await fs.promises.readFile(bundlePath, "utf8");
57
- expect(worker).not.toMatch(
58
- /custom_block|createCustomBlock|react|createRoot|NotionCustomBlock/,
59
- );
149
+ expect(worker).not.toMatch(/custom_block|customBlock|react|createRoot|NotionCustomBlock/);
60
150
  expect(fs.existsSync(path.join(root, ".notion/blocks.js"))).toBe(false);
61
151
  });
62
152
  it("names the block file when path is missing", async () => {
@@ -66,7 +156,7 @@ describe("buildApp", () => {
66
156
  });
67
157
  it("rejects a block file that exports a different capability", async () => {
68
158
  await expect(buildApp(fixture("custom-block-wrong-tag"))).rejects.toThrow(
69
- /hello.ts.*createCustomBlock/,
159
+ /hello.ts.*customBlock/,
70
160
  );
71
161
  });
72
162
 
@@ -91,7 +181,7 @@ describe("buildApp", () => {
91
181
  },
92
182
  },
93
183
  {
94
- key: "homebase-meetings-source",
184
+ key: "homebase-meetings-db-source",
95
185
  config: {
96
186
  initialTitle: "Meetings",
97
187
  schema: {
@@ -122,7 +212,7 @@ describe("buildApp", () => {
122
212
  type: "sync",
123
213
  key: "notionAsCodeIssues",
124
214
  config: {
125
- databaseKey: "homebase-meetings-source",
215
+ databaseKey: "homebase-meetings-db-source",
126
216
  primaryKeyProperty: "Meeting ID",
127
217
  mode: "incremental",
128
218
  schedule: { type: "interval", intervalMs: 3_600_000 },
@@ -233,7 +233,7 @@ export async function extractManifest(
233
233
  if (!tagged || tagged._tag !== capability.tag) {
234
234
  throw new Error(
235
235
  `${capability.sourcePath}: default export is not a ${capability.type} — ` +
236
- `files under this directory must default-export create${capability.type === "custom_block" ? "CustomBlock" : capitalize(capability.type)}(...)`,
236
+ `files under this directory must default-export ${capability.type === "custom_block" ? "customBlock" : capability.type}(...)`,
237
237
  );
238
238
  }
239
239
 
@@ -342,10 +342,6 @@ function getCapabilityRegistry(
342
342
  return bundle.capabilities as Record<string, Record<string, unknown> | undefined>;
343
343
  }
344
344
 
345
- function capitalize(value: string): string {
346
- return `${value.slice(0, 1).toUpperCase()}${value.slice(1)}`;
347
- }
348
-
349
345
  /**
350
346
  * Throw if `value` contains anything `JSON.stringify` would silently drop or
351
347
  * rewrite, so invalid manifest configuration fails during the build.
@@ -0,0 +1,26 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createCapabilityContext } from "./context.js";
3
+
4
+ afterEach(() => {
5
+ delete process.env.NOTION_API_TOKEN;
6
+ delete process.env.NOTION_WORKFLOW_STEP_DIRECTORY;
7
+ });
8
+
9
+ describe("createCapabilityContext", () => {
10
+ it("explains how to configure local Notion API access", () => {
11
+ const context = createCapabilityContext();
12
+
13
+ expect(() => context.notion.pages).toThrow(
14
+ "NOTION_API_TOKEN is not set. context.notion requires an API token when an app capability runs locally.",
15
+ );
16
+ });
17
+
18
+ it("explains how to grant deployed workflow access", () => {
19
+ process.env.NOTION_WORKFLOW_STEP_DIRECTORY = "/tmp/notion-run/steps";
20
+ const context = createCapabilityContext();
21
+
22
+ expect(() => context.notion.pages).toThrow(
23
+ "Notion API access is not configured for this deployed workflow. In Notion, open Developer Tools → Workers",
24
+ );
25
+ });
26
+ });
package/src/context.ts CHANGED
@@ -13,6 +13,14 @@ const LOCAL_FIX_STEPS =
13
13
  "Deployed apps receive Notion API credentials automatically. Do not push NOTION_API_TOKEN.";
14
14
 
15
15
  function missingTokenMessage(): string {
16
+ if (process.env.NOTION_WORKFLOW_STEP_DIRECTORY) {
17
+ return (
18
+ "Notion API access is not configured for this deployed workflow. " +
19
+ "In Notion, open Developer Tools → Workers, select this workflow, and grant access to the pages or databases it uses. " +
20
+ "The runtime provides NOTION_API_TOKEN automatically after access is configured; do not set or push it yourself."
21
+ );
22
+ }
23
+
16
24
  return (
17
25
  "NOTION_API_TOKEN is not set. " +
18
26
  "context.notion requires an API token when an app capability runs locally." +
@@ -1,9 +1,9 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { createCustomBlock } from "./custom-block.js";
2
+ import { customBlock } from "./custom-block.js";
3
3
 
4
- describe("createCustomBlock", () => {
4
+ describe("customBlock", () => {
5
5
  it("supports static assets without a build command", () => {
6
- expect(createCustomBlock({ path: "assets", static: true }).config.source).toEqual({
6
+ expect(customBlock({ path: "assets", static: true }).config.source).toEqual({
7
7
  type: "static",
8
8
  path: "assets",
9
9
  });
@@ -11,7 +11,7 @@ describe("createCustomBlock", () => {
11
11
  it.each(["//hello", "Hello", "-hello", "", "a".repeat(65)])(
12
12
  "rejects invalid slash command %s",
13
13
  (slashCommand) => {
14
- expect(() => createCustomBlock({ path: "blocks/hello", slashCommand })).toThrow(
14
+ expect(() => customBlock({ path: "blocks/hello", slashCommand })).toThrow(
15
15
  /slashCommand/,
16
16
  );
17
17
  },
@@ -10,9 +10,9 @@ export type CustomBlockOptions = {
10
10
  );
11
11
 
12
12
  /** Declare a browser project. Its filename under src/customBlocks supplies its key. */
13
- export function createCustomBlock(options: CustomBlockOptions) {
13
+ export function customBlock(options: CustomBlockOptions) {
14
14
  if (typeof options.path !== "string" || options.path.trim() === "") {
15
- throw new Error("createCustomBlock requires a non-empty path relative to the app root");
15
+ throw new Error("customBlock requires a non-empty path relative to the app root");
16
16
  }
17
17
  const slashCommand = options.slashCommand?.replace(/^\//, "");
18
18
  if (slashCommand !== undefined && !/^[a-z0-9][a-z0-9_-]{0,63}$/.test(slashCommand)) {
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export { customBlock } from "./custom-block.js";
2
+ export { customAgent } from "./notion-as-code/custom-agent.js";
3
+ export { database } from "./notion-as-code/database.js";
4
+ export { page } from "./notion-as-code/page.js";
5
+ export { teamspace } from "./notion-as-code/teamspace.js";
6
+ export { sync } from "./sync.js";
7
+ export { workflow } from "./workflow.js";
@@ -37,6 +37,19 @@ export type ChildDatabaseArgs<
37
37
  DataSources extends readonly DataSourceDefinition[] = readonly DataSourceDefinition[],
38
38
  > = Omit<DatabaseArgs<DataSources>, "parent">;
39
39
 
40
+ /** Single-source shorthand; the source ID is `${resourceId}-source`. */
41
+ export type SingleSourceDatabaseArgs<
42
+ Properties extends readonly NotionAsCodeProperty[] = readonly NotionAsCodeProperty[],
43
+ > = Omit<DatabaseArgs, "dataSources" | "name"> & {
44
+ name: string;
45
+ properties: Properties;
46
+ dataSources?: never;
47
+ };
48
+
49
+ export type ChildSingleSourceDatabaseArgs<
50
+ Properties extends readonly NotionAsCodeProperty[] = readonly NotionAsCodeProperty[],
51
+ > = Omit<SingleSourceDatabaseArgs<Properties>, "parent">;
52
+
40
53
  /** Resolves one data source ID to the properties declared for it. */
41
54
  type DataSourcePropertiesForResourceId<
42
55
  DataSources extends readonly DataSourceDefinition[],
@@ -71,11 +84,32 @@ export type DatabaseHandle<
71
84
  addView: (view: unknown) => void;
72
85
  };
73
86
 
87
+ /** A database whose sole data source is available without an ID lookup. */
88
+ export type SingleSourceDatabaseHandle<
89
+ Properties extends readonly NotionAsCodeProperty[] = readonly NotionAsCodeProperty[],
90
+ > = DatabaseHandle<readonly DataSourceDefinition<Properties>[]> & {
91
+ readonly dataSource: DataSourceHandle<Properties>;
92
+ };
93
+
94
+ export type ChildDatabaseFactory = {
95
+ <const Properties extends readonly NotionAsCodeProperty[]>(
96
+ args: ChildSingleSourceDatabaseArgs<Properties>,
97
+ ): SingleSourceDatabaseHandle<Properties>;
98
+ <const DataSources extends readonly DataSourceDefinition[]>(
99
+ args: ChildDatabaseArgs<DataSources> & { properties?: never },
100
+ ): DatabaseHandle<DataSources>;
101
+ };
102
+
74
103
  /** Declare a database, defaulting to a private top-level database in the Apps workspace. */
104
+ export function database<const Properties extends readonly NotionAsCodeProperty[]>(
105
+ args: SingleSourceDatabaseArgs<Properties>,
106
+ ): SingleSourceDatabaseHandle<Properties>;
75
107
  export function database<const DataSources extends readonly DataSourceDefinition[]>(
76
- args: DatabaseArgs<DataSources>,
108
+ args: DatabaseArgs<DataSources> & { properties?: never },
77
109
  ): DatabaseHandle<DataSources>;
78
- export function database(args: DatabaseArgs): DatabaseHandle {
110
+ export function database(
111
+ args: DatabaseArgs | SingleSourceDatabaseArgs,
112
+ ): DatabaseHandle | SingleSourceDatabaseHandle {
79
113
  if (args.parent) {
80
114
  assertParent(args.parent);
81
115
  }
@@ -83,9 +117,45 @@ export function database(args: DatabaseArgs): DatabaseHandle {
83
117
  type: "resourceId",
84
118
  resourceId: APPS_WORKSPACE_RESOURCE_ID,
85
119
  };
120
+ if ("properties" in args) {
121
+ if (args.dataSources !== undefined) {
122
+ throw new Error("Specify either properties or dataSources, not both");
123
+ }
124
+ const { properties, ...databaseArgs } = args;
125
+ const source: DataSourceDefinition = {
126
+ resourceId: `${args.resourceId}-source`,
127
+ name: args.name,
128
+ properties,
129
+ };
130
+ const handle = createDatabase({ ...databaseArgs, dataSources: [source] }, parent);
131
+ const dataSource = handle.dataSources[source.resourceId];
132
+ if (!dataSource) {
133
+ throw new Error(`Missing data source "${source.resourceId}"`);
134
+ }
135
+ return { ...handle, dataSource };
136
+ }
86
137
  return createDatabase(args, parent);
87
138
  }
88
139
 
140
+ /** Bind both database declaration forms to an existing parent. */
141
+ export function createChildDatabase(parent: Parent): ChildDatabaseFactory {
142
+ function addDatabase<const Properties extends readonly NotionAsCodeProperty[]>(
143
+ args: ChildSingleSourceDatabaseArgs<Properties>,
144
+ ): SingleSourceDatabaseHandle<Properties>;
145
+ function addDatabase<const DataSources extends readonly DataSourceDefinition[]>(
146
+ args: ChildDatabaseArgs<DataSources> & { properties?: never },
147
+ ): DatabaseHandle<DataSources>;
148
+ function addDatabase(
149
+ args: ChildDatabaseArgs | ChildSingleSourceDatabaseArgs,
150
+ ): DatabaseHandle | SingleSourceDatabaseHandle {
151
+ if ("properties" in args) {
152
+ return database({ ...args, parent });
153
+ }
154
+ return database({ ...args, parent });
155
+ }
156
+ return addDatabase;
157
+ }
158
+
89
159
  /** Record a database declaration and create handles for each of its data sources. */
90
160
  export function createDatabase(args: DatabaseArgs, parent: Parent): DatabaseHandle {
91
161
  assertUserResourceId(args.resourceId);
@@ -5,9 +5,13 @@ export type { DataSourceDefinition, NotionAsCodeProperty, Parent } from "./inten
5
5
  export type { CustomAgentArgs, CustomAgentHandle } from "./custom-agent.js";
6
6
  export type {
7
7
  ChildDatabaseArgs,
8
+ ChildDatabaseFactory,
9
+ ChildSingleSourceDatabaseArgs,
8
10
  DataSourceHandle,
9
11
  DatabaseArgs,
10
12
  DatabaseHandle,
13
+ SingleSourceDatabaseArgs,
14
+ SingleSourceDatabaseHandle,
11
15
  } from "./database.js";
12
16
  export type { ChildPageArgs, PageArgs, PageHandle } from "./page.js";
13
17
  export type { TeamspaceArgs, TeamspaceHandle } from "./teamspace.js";
@@ -1,11 +1,10 @@
1
1
  import {
2
2
  APPS_WORKSPACE_RESOURCE_ID,
3
- type DataSourceDefinition,
4
3
  type PageIntent,
5
4
  type Parent,
6
5
  type ResourceId,
7
6
  } from "./intents.js";
8
- import { database, type ChildDatabaseArgs, type DatabaseHandle } from "./database.js";
7
+ import { createChildDatabase, type ChildDatabaseFactory } from "./database.js";
9
8
  import { recordIntent } from "./recorder.js";
10
9
  import { assertParent, assertUserResourceId } from "./resource.js";
11
10
 
@@ -25,9 +24,7 @@ export type ChildPageArgs = Omit<PageArgs, "parent">;
25
24
 
26
25
  export type PageHandle = {
27
26
  readonly resourceId: string;
28
- addDatabase: <const DataSources extends readonly DataSourceDefinition[]>(
29
- args: ChildDatabaseArgs<DataSources>,
30
- ) => DatabaseHandle<DataSources>;
27
+ addDatabase: ChildDatabaseFactory;
31
28
  addPage: (args: ChildPageArgs) => PageHandle;
32
29
  };
33
30
 
@@ -54,12 +51,7 @@ export function createPage(args: ChildPageArgs | PageArgs, parent: Parent): Page
54
51
 
55
52
  return {
56
53
  resourceId: args.resourceId,
57
- addDatabase(databaseArgs) {
58
- return database({
59
- ...databaseArgs,
60
- parent: { type: "resourceId", resourceId: args.resourceId },
61
- });
62
- },
54
+ addDatabase: createChildDatabase({ type: "resourceId", resourceId: args.resourceId }),
63
55
  addPage(pageArgs) {
64
56
  return createPage(pageArgs, { type: "resourceId", resourceId: args.resourceId });
65
57
  },
@@ -1,11 +1,10 @@
1
1
  import {
2
2
  APPS_WORKSPACE_RESOURCE_ID,
3
- type DataSourceDefinition,
4
3
  type NotionAsCodeIcon,
5
4
  type ResourceId,
6
5
  type TeamspaceIntent,
7
6
  } from "./intents.js";
8
- import { database, type ChildDatabaseArgs, type DatabaseHandle } from "./database.js";
7
+ import { createChildDatabase, type ChildDatabaseFactory } from "./database.js";
9
8
  import { createPage, type ChildPageArgs, type PageHandle } from "./page.js";
10
9
  import { recordIntent } from "./recorder.js";
11
10
  import { assertUserResourceId } from "./resource.js";
@@ -20,9 +19,7 @@ export type TeamspaceArgs = {
20
19
 
21
20
  export type TeamspaceHandle = {
22
21
  readonly resourceId: string;
23
- addDatabase: <const DataSources extends readonly DataSourceDefinition[]>(
24
- args: ChildDatabaseArgs<DataSources>,
25
- ) => DatabaseHandle<DataSources>;
22
+ addDatabase: ChildDatabaseFactory;
26
23
  addPage: (args: ChildPageArgs) => PageHandle;
27
24
  };
28
25
 
@@ -37,12 +34,7 @@ export function teamspace(args: TeamspaceArgs): TeamspaceHandle {
37
34
 
38
35
  return {
39
36
  resourceId: args.resourceId,
40
- addDatabase(databaseArgs) {
41
- return database({
42
- ...databaseArgs,
43
- parent: { type: "resourceId", resourceId: args.resourceId },
44
- });
45
- },
37
+ addDatabase: createChildDatabase({ type: "resourceId", resourceId: args.resourceId }),
46
38
  addPage(pageArgs) {
47
39
  return createPage(pageArgs, { type: "resourceId", resourceId: args.resourceId });
48
40
  },
package/src/sync.ts CHANGED
@@ -89,7 +89,7 @@ export type SyncConfiguration<
89
89
  };
90
90
 
91
91
  /** Configuration for a sync backed by a Notion-as-Code data source. */
92
- type DataSourceSyncConfiguration<
92
+ export type DataSourceSyncConfiguration<
93
93
  Properties extends readonly NotionAsCodeProperty[],
94
94
  PrimaryKey extends KeyPropertyName<AppsSchemaForProperties<Properties>>,
95
95
  State,
@@ -165,7 +165,7 @@ export function createSync<
165
165
  }
166
166
 
167
167
  /** Create a sync backed by a Notion-as-Code data source. */
168
- export function createDataSourceSync<
168
+ export function sync<
169
169
  const Properties extends readonly NotionAsCodeProperty[],
170
170
  const PrimaryKey extends KeyPropertyName<AppsSchemaForProperties<Properties>>,
171
171
  State = unknown,
@@ -1,9 +1,9 @@
1
1
  import { expectTypeOf, it } from "vitest";
2
2
  import { triggers } from "./triggers.generated.js";
3
- import { connections, createWorkflow, type WorkflowConnection } from "./workflow.js";
3
+ import { connections, workflow, type WorkflowConnection } from "./workflow.js";
4
4
 
5
5
  it("infers only the declared provider clients", () => {
6
- createWorkflow({
6
+ workflow({
7
7
  name: "Calendar",
8
8
  description: "Calendar only",
9
9
  triggers: [triggers.notionPageCreated()],
@@ -14,7 +14,7 @@ it("infers only the declared provider clients", () => {
14
14
  },
15
15
  });
16
16
  const requirements = [connections.calendar(), connections.slack({ key: "support" })];
17
- createWorkflow({
17
+ workflow({
18
18
  name: "Multiple providers",
19
19
  description: "Preserves providers in a separately declared array",
20
20
  triggers: [triggers.notionPageCreated()],
@@ -26,7 +26,7 @@ it("infers only the declared provider clients", () => {
26
26
  });
27
27
 
28
28
  it("exposes no providers for omitted or empty connections", () => {
29
- createWorkflow({
29
+ workflow({
30
30
  name: "No connections",
31
31
  description: "Omitted",
32
32
  triggers: [triggers.notionPageCreated()],
@@ -34,7 +34,7 @@ it("exposes no providers for omitted or empty connections", () => {
34
34
  expectTypeOf<keyof typeof context.connections>().toEqualTypeOf<never>();
35
35
  },
36
36
  });
37
- createWorkflow({
37
+ workflow({
38
38
  name: "No connections",
39
39
  description: "Empty",
40
40
  triggers: [triggers.notionPageCreated()],
@@ -47,7 +47,7 @@ it("exposes no providers for omitted or empty connections", () => {
47
47
 
48
48
  it("preserves literal declarations checked with satisfies", () => {
49
49
  const requirements = [connections.slack()] satisfies readonly WorkflowConnection[];
50
- createWorkflow({
50
+ workflow({
51
51
  name: "Slack",
52
52
  description: "Slack only",
53
53
  triggers: [triggers.notionPageCreated()],
@@ -93,10 +93,10 @@ it("checks provider keys on callback arguments and returned triggers", () => {
93
93
  const fixtures = new Map(
94
94
  cases.map((testCase) => [
95
95
  resolve("src", `__workflow_types_${testCase.name}.ts`),
96
- `import { connections, createWorkflow } from "./workflow.js";
96
+ `import { connections, workflow } from "./workflow.js";
97
97
  import { triggers as globalTriggers } from "./triggers.generated.js";
98
98
  const support = connections.slack({ key: "support" });
99
- createWorkflow({
99
+ workflow({
100
100
  name: "Type check", description: "Type check",
101
101
  ${testCase.connections === undefined ? "" : `connections: ${testCase.connections},`}
102
102
  triggers: ({ triggers }) => [${testCase.trigger}],