@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.
- package/AGENTS.md +13 -0
- package/README.md +126 -27
- package/dist/cli/emit-manifest.d.ts.map +1 -1
- package/dist/cli/emit-manifest.js +1 -4
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +3 -0
- package/dist/context.test.d.ts +2 -0
- package/dist/context.test.d.ts.map +1 -0
- package/dist/custom-block.d.ts +1 -1
- package/dist/custom-block.d.ts.map +1 -1
- package/dist/custom-block.js +3 -3
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/notion-as-code/database.d.ts +23 -1
- package/dist/notion-as-code/database.d.ts.map +1 -1
- package/dist/notion-as-code/database.js +27 -0
- package/dist/notion-as-code/index.d.ts +1 -1
- package/dist/notion-as-code/index.d.ts.map +1 -1
- package/dist/notion-as-code/page.d.ts +3 -3
- package/dist/notion-as-code/page.d.ts.map +1 -1
- package/dist/notion-as-code/page.js +2 -7
- package/dist/notion-as-code/teamspace.d.ts +3 -3
- package/dist/notion-as-code/teamspace.d.ts.map +1 -1
- package/dist/notion-as-code/teamspace.js +2 -7
- package/dist/sync.d.ts +2 -2
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js +3 -3
- package/dist/workflow.d.ts +6 -6
- package/dist/workflow.d.ts.map +1 -1
- package/dist/workflow.js +2 -2
- package/docs/BUILD.md +5 -5
- package/docs/CONNECTIONS.md +5 -4
- package/package.json +7 -1
- package/skills/connections/SKILL.md +86 -0
- package/skills/custom-blocks/SKILL.md +45 -0
- package/skills/notion-as-code/SKILL.md +189 -0
- package/skills/sync/SKILL.md +98 -0
- package/skills/workflow/SKILL.md +123 -0
- package/src/cli/build.test.ts +97 -7
- package/src/cli/emit-manifest.ts +1 -5
- package/src/context.test.ts +26 -0
- package/src/context.ts +8 -0
- package/src/custom-block.test.ts +4 -4
- package/src/custom-block.ts +2 -2
- package/src/index.ts +7 -0
- package/src/notion-as-code/database.ts +72 -2
- package/src/notion-as-code/index.ts +4 -0
- package/src/notion-as-code/page.ts +3 -11
- package/src/notion-as-code/teamspace.ts +3 -11
- package/src/sync.ts +2 -2
- package/src/workflow-connections-types.test.ts +6 -6
- package/src/workflow-types.test.ts +2 -2
- package/src/workflow.test.ts +31 -31
- 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.
|
package/src/cli/build.test.ts
CHANGED
|
@@ -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.*
|
|
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 },
|
package/src/cli/emit-manifest.ts
CHANGED
|
@@ -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
|
|
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." +
|
package/src/custom-block.test.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import { customBlock } from "./custom-block.js";
|
|
3
3
|
|
|
4
|
-
describe("
|
|
4
|
+
describe("customBlock", () => {
|
|
5
5
|
it("supports static assets without a build command", () => {
|
|
6
|
-
expect(
|
|
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(() =>
|
|
14
|
+
expect(() => customBlock({ path: "blocks/hello", slashCommand })).toThrow(
|
|
15
15
|
/slashCommand/,
|
|
16
16
|
);
|
|
17
17
|
},
|
package/src/custom-block.ts
CHANGED
|
@@ -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
|
|
13
|
+
export function customBlock(options: CustomBlockOptions) {
|
|
14
14
|
if (typeof options.path !== "string" || options.path.trim() === "") {
|
|
15
|
-
throw new Error("
|
|
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(
|
|
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 {
|
|
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:
|
|
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(
|
|
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 {
|
|
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:
|
|
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(
|
|
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
|
|
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,
|
|
3
|
+
import { connections, workflow, type WorkflowConnection } from "./workflow.js";
|
|
4
4
|
|
|
5
5
|
it("infers only the declared provider clients", () => {
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
99
|
+
workflow({
|
|
100
100
|
name: "Type check", description: "Type check",
|
|
101
101
|
${testCase.connections === undefined ? "" : `connections: ${testCase.connections},`}
|
|
102
102
|
triggers: ({ triggers }) => [${testCase.trigger}],
|