@notionhq/apps 0.0.17 → 0.0.19
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 +27 -0
- package/README.md +77 -35
- package/dist/cli/build.d.ts.map +1 -1
- package/dist/cli/build.js +12 -7
- package/dist/cli/emit-manifest.d.ts +1 -1
- package/dist/cli/emit-manifest.d.ts.map +1 -1
- package/dist/cli/emit-manifest.js +66 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/notion-as-code/custom-agent.d.ts +1 -0
- package/dist/notion-as-code/custom-agent.d.ts.map +1 -1
- package/dist/notion-as-code/custom-agent.js +1 -1
- package/dist/notion-as-code/database.d.ts +90 -48
- package/dist/notion-as-code/database.d.ts.map +1 -1
- package/dist/notion-as-code/database.js +135 -45
- package/dist/notion-as-code/database.test.d.ts +2 -0
- package/dist/notion-as-code/database.test.d.ts.map +1 -0
- package/dist/notion-as-code/index.d.ts +2 -2
- package/dist/notion-as-code/index.d.ts.map +1 -1
- package/dist/notion-as-code/intents.d.ts +34 -2
- package/dist/notion-as-code/intents.d.ts.map +1 -1
- package/dist/notion-as-code/page.d.ts +1 -0
- package/dist/notion-as-code/page.d.ts.map +1 -1
- package/dist/notion-as-code/page.js +1 -0
- package/dist/notion-as-code/schema.d.ts +8 -4
- package/dist/notion-as-code/schema.d.ts.map +1 -1
- package/dist/notion-as-code/views.d.ts +29 -10
- package/dist/notion-as-code/views.d.ts.map +1 -1
- package/dist/sync.d.ts +33 -15
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js +12 -0
- package/dist/workflow-access-types.test.d.ts +2 -0
- package/dist/workflow-access-types.test.d.ts.map +1 -0
- package/dist/workflow-access.d.ts +68 -0
- package/dist/workflow-access.d.ts.map +1 -0
- package/dist/workflow-access.js +135 -0
- package/dist/workflow-state.d.ts +13 -0
- package/dist/workflow-state.d.ts.map +1 -0
- package/dist/workflow-state.js +107 -0
- package/dist/workflow.d.ts +65 -9
- package/dist/workflow.d.ts.map +1 -1
- package/dist/workflow.js +119 -5
- package/docs/BUILD.md +134 -4
- package/package.json +1 -1
- package/skills/notion-as-code/SKILL.md +89 -49
- package/skills/sync/SKILL.md +21 -18
- package/skills/workflow/SKILL.md +101 -17
- package/src/cli/build.test.ts +148 -64
- package/src/cli/build.ts +13 -8
- package/src/cli/emit-manifest.ts +107 -0
- package/src/index.ts +21 -1
- package/src/notion-as-code/custom-agent.ts +2 -1
- package/src/notion-as-code/database.test.ts +661 -0
- package/src/notion-as-code/database.ts +349 -127
- package/src/notion-as-code/index.ts +16 -1
- package/src/notion-as-code/intents.ts +39 -2
- package/src/notion-as-code/page.ts +2 -0
- package/src/notion-as-code/schema.ts +11 -3
- package/src/notion-as-code/views.ts +29 -10
- package/src/sync.test.ts +295 -0
- package/src/sync.ts +85 -21
- package/src/workflow-access-types.test.ts +69 -0
- package/src/workflow-access.ts +299 -0
- package/src/workflow-state.ts +152 -0
- package/src/workflow.test.ts +441 -1
- package/src/workflow.ts +230 -13
package/skills/workflow/SKILL.md
CHANGED
|
@@ -38,6 +38,53 @@ and Notion API calls, mutable state reads, timestamps, random values, generated
|
|
|
38
38
|
IDs, messages, creates, and updates. Keep deterministic transforms of the event
|
|
39
39
|
and completed step results outside a step.
|
|
40
40
|
|
|
41
|
+
Workflow-level key-value state is available through the step closure's `state`.
|
|
42
|
+
State is shared across runs of the same workflow and every operation executes
|
|
43
|
+
inside that step's durability boundary:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
await context.step("Update cursor", async ({ state }) => {
|
|
47
|
+
state.set("cursor", nextCursor);
|
|
48
|
+
const pendingCursor = await state.get("cursor"); // Reads the buffered write.
|
|
49
|
+
if (!(await saveCursor(pendingCursor))) {
|
|
50
|
+
throw new Error("Save failed"); // The state write is discarded.
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Use `get` and `set`; passing `undefined` to `set` deletes the key, while `null`
|
|
56
|
+
is stored as a value. Mutations are buffered and committed atomically only
|
|
57
|
+
after the step callback succeeds. Repeated mutations of one key are allowed,
|
|
58
|
+
reads observe the latest buffered value, and only the final mutation is
|
|
59
|
+
committed. If the callback throws, none of that step's state mutations are
|
|
60
|
+
persisted.
|
|
61
|
+
|
|
62
|
+
Use `context.wait.until(name, { after })` for a relative wait shorter than seven
|
|
63
|
+
days, or `context.wait.until(name, { at })` for an absolute date within the next
|
|
64
|
+
seven days. The runtime checkpoints the target time, releases the workflow's
|
|
65
|
+
compute, and resumes it from the same point later:
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
await context.wait.until("Cooling-off period", {
|
|
69
|
+
after: { days: 1, hours: 6 },
|
|
70
|
+
});
|
|
71
|
+
await context.wait.until("Launch time", { at: launchAt });
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Relative durations can combine `milliseconds`, `seconds`, `minutes`, `hours`,
|
|
75
|
+
`days`, and `weeks`. Every specified value must be a positive finite integer.
|
|
76
|
+
Repeated waits can keep one stable display name when each call has a unique
|
|
77
|
+
composite `key`:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
for (const page of pages) {
|
|
81
|
+
await context.wait.until("Rate limit delay", {
|
|
82
|
+
key: ["rate-limit", page.id],
|
|
83
|
+
after: { seconds: 1 },
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
41
88
|
Completed steps replay saved results. Do not rely on in-memory mutations inside
|
|
42
89
|
a step. Return the values required by later code. Keep calls in a stable order
|
|
43
90
|
and give every step a stable display name. The name is the replay key by
|
|
@@ -63,12 +110,13 @@ configuration, and do not log secrets or private payloads.
|
|
|
63
110
|
|
|
64
111
|
## Resources created with the App
|
|
65
112
|
|
|
66
|
-
Use [Notion as Code](../notion-as-code/SKILL.md) for pages and
|
|
67
|
-
should be created during deployment. Prefer it over equivalent manual setup
|
|
113
|
+
Use [Notion as Code](../notion-as-code/SKILL.md) for pages, databases, and custom agents
|
|
114
|
+
that should be created during deployment. Prefer it over equivalent manual setup
|
|
68
115
|
or API calls that create the App's resources. Use runtime API calls for dynamic
|
|
69
116
|
data changes, not as a substitute for supported Notion as Code setup.
|
|
70
|
-
Keep declarations at module scope
|
|
71
|
-
|
|
117
|
+
Keep declarations at module scope. A small workflow-specific resource can be
|
|
118
|
+
declared inline. A larger set can live in a local module; for example, declare a
|
|
119
|
+
guide page in `src/workflows/sayHello/lib/notion.ts`:
|
|
72
120
|
|
|
73
121
|
```ts
|
|
74
122
|
import { page } from "@notionhq/apps";
|
|
@@ -79,31 +127,67 @@ export const guide = page({
|
|
|
79
127
|
});
|
|
80
128
|
```
|
|
81
129
|
|
|
82
|
-
Import the
|
|
130
|
+
Import the handle into the workflow and declare the named binding it needs:
|
|
83
131
|
|
|
84
132
|
```ts
|
|
85
|
-
import "../lib/resources";
|
|
86
133
|
import { workflow } from "@notionhq/apps";
|
|
87
134
|
import { triggers } from "@notionhq/apps/triggers";
|
|
88
135
|
|
|
136
|
+
import { guide } from "./sayHello/lib/notion.js";
|
|
137
|
+
|
|
89
138
|
export default workflow({
|
|
90
|
-
name: "
|
|
91
|
-
description: "
|
|
139
|
+
name: "Read guide",
|
|
140
|
+
description: "Reads the App's guide on a recurring schedule.",
|
|
92
141
|
triggers: [triggers.scheduled()],
|
|
142
|
+
access: { guide: { resource: guide, level: "view" } },
|
|
93
143
|
handler: async (_event, context) => {
|
|
94
|
-
await context.step("
|
|
95
|
-
|
|
96
|
-
|
|
144
|
+
await context.step("Read guide", () =>
|
|
145
|
+
context.notion.pages.retrieve({ page_id: context.access.guide.id }),
|
|
146
|
+
);
|
|
97
147
|
},
|
|
98
148
|
});
|
|
99
149
|
```
|
|
100
150
|
|
|
101
|
-
The page is provisioned during deployment, not on every workflow run.
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
151
|
+
The page is provisioned during deployment, not on every workflow run. Inline
|
|
152
|
+
declarations in `access` work too. Importing a declaration alone provisions the
|
|
153
|
+
resource but does not create a `context.access` alias. An `access` entry declares
|
|
154
|
+
the needed permission level and a named binding for that NaC resource.
|
|
155
|
+
|
|
156
|
+
`access` accepts page, database, data-source, and custom-agent handles. Pages,
|
|
157
|
+
databases, and data sources support `view`, `comment`, `edit`, and `fullAccess`;
|
|
158
|
+
custom agents support only `call`. Keys are workflow-local aliases. The handler's
|
|
159
|
+
typed `context.access` exposes each declared alias as a readonly `{ type, id }`,
|
|
160
|
+
not the declaration handle or a permission-management API. UI-granted resources
|
|
161
|
+
are permissions only: they do not create `context.access` aliases.
|
|
162
|
+
|
|
163
|
+
For a resource already granted to the workflow in Notion's UI, use its live
|
|
164
|
+
record ID directly in a `context.notion` call:
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
await context.step("Read granted page", () =>
|
|
168
|
+
context.notion.pages.retrieve({ page_id: "page-id" }),
|
|
169
|
+
);
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Such a UI grant needs no `access` entry, NaC declaration, or NaC `resourceId`.
|
|
173
|
+
The call remains subject to the workflow's existing Notion-module permissions.
|
|
174
|
+
|
|
175
|
+
Use a data-source handle when calling `context.notion.dataSources`: its resolved
|
|
176
|
+
ID is a collection ID. A database handle resolves to the database block ID;
|
|
177
|
+
these identities are not interchangeable. Notion as Code `resourceId` values
|
|
178
|
+
remain declaration identities, not live Notion UUIDs.
|
|
179
|
+
|
|
180
|
+
Cloud deployment resolves declared handles to live IDs and reconciles their
|
|
181
|
+
grants through the existing Notion-module permissions. It saves permissions and
|
|
182
|
+
bindings together; bindings themselves do not authorize calls. Changing `access`
|
|
183
|
+
updates code-managed grants and bindings while preserving manually configured
|
|
184
|
+
UI grants. Normal draft/publish and runtime authorization still apply. Missing
|
|
185
|
+
or mismatched required bindings fail before the handler runs.
|
|
186
|
+
|
|
187
|
+
Deploy workflows with nonempty `access` using `ntn apps deploy`, without
|
|
188
|
+
`--local-build`. Local-build deployment rejects them before upload. Runtime API
|
|
189
|
+
calls still belong in durable steps. For syncs, see the
|
|
190
|
+
[data-source example](../sync/SKILL.md#choose-the-database-source).
|
|
107
191
|
|
|
108
192
|
## Review a workflow
|
|
109
193
|
|
package/src/cli/build.test.ts
CHANGED
|
@@ -15,17 +15,18 @@ import { finishMetadataRecording, startMetadataRecording } from "../notion-as-co
|
|
|
15
15
|
describe("Notion-as-Code database shorthand", () => {
|
|
16
16
|
it("keeps provisioning and sync identity equivalent to an explicit source", () => {
|
|
17
17
|
const recorder = startMetadataRecording();
|
|
18
|
-
const
|
|
19
|
-
resourceId: "
|
|
18
|
+
const schema = {
|
|
19
|
+
Name: { resourceId: "title", type: "title" },
|
|
20
|
+
Count: { resourceId: "count", type: "number" },
|
|
21
|
+
} as const;
|
|
22
|
+
const database = notion.database("issues", {
|
|
23
|
+
dataSourceResourceId: "issues-source",
|
|
20
24
|
name: "Issues",
|
|
21
|
-
|
|
22
|
-
{ resourceId: "title", name: "Name", type: "title" },
|
|
23
|
-
{ resourceId: "count", name: "Count", type: "number" },
|
|
24
|
-
],
|
|
25
|
+
schema,
|
|
25
26
|
});
|
|
26
|
-
expectTypeOf(database).toExtend<
|
|
27
|
-
|
|
28
|
-
>();
|
|
27
|
+
expectTypeOf(database).toExtend<SingleSourceDatabaseHandle<typeof schema>>();
|
|
28
|
+
expectTypeOf(database.dataSource.schema.Name.type).toEqualTypeOf<"title">();
|
|
29
|
+
expectTypeOf(database.dataSource.schema.Name.resourceId).toEqualTypeOf<"title">();
|
|
29
30
|
expectTypeOf(
|
|
30
31
|
database.dataSource.database.config.schema.Count.type,
|
|
31
32
|
).toEqualTypeOf<"number">();
|
|
@@ -33,18 +34,19 @@ describe("Notion-as-Code database shorthand", () => {
|
|
|
33
34
|
const shorthand = finishMetadataRecording(recorder).intents;
|
|
34
35
|
|
|
35
36
|
const explicitRecorder = startMetadataRecording();
|
|
36
|
-
const explicit = notion.database({
|
|
37
|
-
resourceId: "issues",
|
|
37
|
+
const explicit = notion.database("issues", {
|
|
38
38
|
name: "Issues",
|
|
39
|
-
|
|
40
|
-
{
|
|
39
|
+
datasources: {
|
|
40
|
+
Issues: {
|
|
41
41
|
resourceId: "issues-source",
|
|
42
|
-
|
|
43
|
-
properties: database.dataSource.schema,
|
|
42
|
+
schema,
|
|
44
43
|
},
|
|
45
|
-
|
|
44
|
+
},
|
|
46
45
|
});
|
|
47
|
-
explicit.
|
|
46
|
+
expectTypeOf(explicit.datasources.Issues).toExtend<DataSourceHandle<typeof schema>>();
|
|
47
|
+
expectTypeOf(explicit.datasources.Issues.schema.Name.type).toEqualTypeOf<"title">();
|
|
48
|
+
expectTypeOf(explicit.datasources.Issues.schema.Name.resourceId).toEqualTypeOf<"title">();
|
|
49
|
+
explicit.datasources.Issues.addPage({ resourceId: "issue" });
|
|
48
50
|
// @ts-expect-error Explicit databases do not guarantee a single source.
|
|
49
51
|
void explicit.dataSource;
|
|
50
52
|
expect(shorthand).toEqual(finishMetadataRecording(explicitRecorder).intents);
|
|
@@ -56,22 +58,24 @@ describe("Notion-as-Code database shorthand", () => {
|
|
|
56
58
|
});
|
|
57
59
|
});
|
|
58
60
|
|
|
59
|
-
it("preserves parent binding and
|
|
61
|
+
it("preserves parent binding and keyed source types through child helpers", () => {
|
|
60
62
|
const recorder = startMetadataRecording();
|
|
61
63
|
const page = notion.page({ resourceId: "page" });
|
|
62
64
|
const team = notion.teamspace({ resourceId: "team", name: "Team", accessLevel: "private" });
|
|
65
|
+
const schema = {
|
|
66
|
+
Title: { resourceId: "child-title", type: "title" },
|
|
67
|
+
} as const;
|
|
63
68
|
for (const parent of [page, team]) {
|
|
64
|
-
const database = parent.addDatabase({
|
|
65
|
-
|
|
69
|
+
const database = parent.addDatabase(`${parent.resourceId}-db`, {
|
|
70
|
+
dataSourceResourceId: `${parent.resourceId}-source`,
|
|
66
71
|
name: "Child",
|
|
67
|
-
|
|
68
|
-
{ resourceId: `${parent.resourceId}-title`, name: "Title", type: "title" },
|
|
69
|
-
],
|
|
72
|
+
schema,
|
|
70
73
|
});
|
|
71
|
-
expectTypeOf(database.dataSource).toExtend<
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
74
|
+
expectTypeOf(database.dataSource).toExtend<DataSourceHandle<typeof schema>>();
|
|
75
|
+
expectTypeOf(database.dataSource.schema.Title.type).toEqualTypeOf<"title">();
|
|
76
|
+
expectTypeOf(
|
|
77
|
+
database.dataSource.schema.Title.resourceId,
|
|
78
|
+
).toEqualTypeOf<"child-title">();
|
|
75
79
|
}
|
|
76
80
|
const intents = finishMetadataRecording(recorder).intents;
|
|
77
81
|
expect(
|
|
@@ -82,19 +86,60 @@ describe("Notion-as-Code database shorthand", () => {
|
|
|
82
86
|
]);
|
|
83
87
|
});
|
|
84
88
|
|
|
85
|
-
it("
|
|
89
|
+
it("keeps keyed child inference and rejects empty child databases", () => {
|
|
86
90
|
const recorder = startMetadataRecording();
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
91
|
+
const page = notion.page({ resourceId: "child-page" });
|
|
92
|
+
const schema = {
|
|
93
|
+
Name: { type: "title", resourceId: "child-name" },
|
|
94
|
+
"Issue ID": { type: "text", resourceId: "child-issue-id" },
|
|
95
|
+
} as const;
|
|
96
|
+
const database = page.addDatabase("child-multi-database", {
|
|
97
|
+
datasources: {
|
|
98
|
+
Issues: {
|
|
99
|
+
resourceId: "child-issues-source",
|
|
100
|
+
schema,
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
expectTypeOf(database.datasources.Issues).toExtend<DataSourceHandle<typeof schema>>();
|
|
105
|
+
expectTypeOf(
|
|
106
|
+
database.datasources.Issues.schema.Name.resourceId,
|
|
107
|
+
).toEqualTypeOf<"child-name">();
|
|
108
|
+
|
|
109
|
+
const invalidChildDeclarations = () => {
|
|
110
|
+
// @ts-expect-error Child databases need a source or a view.
|
|
111
|
+
page.addDatabase("child-empty-datasources", { datasources: {} });
|
|
112
|
+
// @ts-expect-error Child databases need a source or a view.
|
|
113
|
+
page.addDatabase("child-empty-arguments", {});
|
|
114
|
+
// @ts-expect-error Empty child sources and views are invalid.
|
|
115
|
+
page.addDatabase("child-empty-with-views", { datasources: {}, views: [] });
|
|
95
116
|
};
|
|
96
|
-
|
|
97
|
-
expect(
|
|
117
|
+
void invalidChildDeclarations;
|
|
118
|
+
expect(() =>
|
|
119
|
+
page.addDatabase("child-runtime-empty", { datasources: {}, views: [] } as never),
|
|
120
|
+
).toThrow();
|
|
121
|
+
|
|
122
|
+
const intents = finishMetadataRecording(recorder).intents;
|
|
123
|
+
expect(intents).toHaveLength(2);
|
|
124
|
+
expect(intents).toEqual(
|
|
125
|
+
expect.arrayContaining([
|
|
126
|
+
expect.objectContaining({
|
|
127
|
+
type: "database",
|
|
128
|
+
resourceId: "child-multi-database",
|
|
129
|
+
parent: { type: "resourceId", resourceId: "child-page" },
|
|
130
|
+
dataSources: [
|
|
131
|
+
{
|
|
132
|
+
resourceId: "child-issues-source",
|
|
133
|
+
name: "Issues",
|
|
134
|
+
properties: [
|
|
135
|
+
{ resourceId: "child-name", name: "Name", type: "title" },
|
|
136
|
+
{ resourceId: "child-issue-id", name: "Issue ID", type: "text" },
|
|
137
|
+
],
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
}),
|
|
141
|
+
]),
|
|
142
|
+
);
|
|
98
143
|
});
|
|
99
144
|
});
|
|
100
145
|
|
|
@@ -159,45 +204,77 @@ describe("buildApp", () => {
|
|
|
159
204
|
/hello.ts.*customBlock/,
|
|
160
205
|
);
|
|
161
206
|
});
|
|
207
|
+
it("emits symbolic access requirements for every NaC resource kind", async () => {
|
|
208
|
+
const { manifest } = await buildApp(fixture("workflow-access"));
|
|
209
|
+
expect(manifest.capabilities).toEqual([
|
|
210
|
+
{
|
|
211
|
+
type: "workflow",
|
|
212
|
+
key: "access",
|
|
213
|
+
config: {
|
|
214
|
+
name: "Access",
|
|
215
|
+
description: "Declares all workflow access resource kinds",
|
|
216
|
+
triggers: [{ type: "notion.page.created" }],
|
|
217
|
+
access: {
|
|
218
|
+
home: { type: "page", resourceId: "home", level: "view" },
|
|
219
|
+
records: { type: "database", resourceId: "records", level: "edit" },
|
|
220
|
+
source: {
|
|
221
|
+
type: "dataSource",
|
|
222
|
+
resourceId: "records-source",
|
|
223
|
+
level: "comment",
|
|
224
|
+
},
|
|
225
|
+
agent: { type: "customAgent", resourceId: "agent", level: "call" },
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
]);
|
|
230
|
+
});
|
|
162
231
|
|
|
163
232
|
it("builds a sync manifest with a Notion-as-Code meetings database", async () => {
|
|
164
233
|
const { manifest } = await buildApp(example("sync"));
|
|
165
234
|
|
|
166
|
-
expect(manifest).toEqual(
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
235
|
+
expect(manifest).toEqual(
|
|
236
|
+
expect.objectContaining({
|
|
237
|
+
$schema: "notion:apps-manifest:v1",
|
|
238
|
+
sdkVersion: SDK_VERSION,
|
|
239
|
+
xldbs: [],
|
|
240
|
+
pacers: [],
|
|
241
|
+
}),
|
|
242
|
+
);
|
|
243
|
+
expect(manifest.databases).toHaveLength(2);
|
|
244
|
+
expect(manifest.databases).toEqual(
|
|
245
|
+
expect.arrayContaining([
|
|
246
|
+
expect.objectContaining({
|
|
171
247
|
key: "issues",
|
|
172
|
-
config: {
|
|
248
|
+
config: expect.objectContaining({
|
|
173
249
|
initialTitle: "GitHub Issues",
|
|
174
|
-
schema: {
|
|
175
|
-
properties: {
|
|
250
|
+
schema: expect.objectContaining({
|
|
251
|
+
properties: expect.objectContaining({
|
|
176
252
|
Name: { type: "title" },
|
|
177
253
|
"GitHub ID": { type: "text" },
|
|
178
254
|
Closed: { type: "checkbox" },
|
|
179
|
-
},
|
|
180
|
-
},
|
|
181
|
-
},
|
|
182
|
-
},
|
|
183
|
-
{
|
|
255
|
+
}),
|
|
256
|
+
}),
|
|
257
|
+
}),
|
|
258
|
+
}),
|
|
259
|
+
expect.objectContaining({
|
|
184
260
|
key: "homebase-meetings-db-source",
|
|
185
|
-
config: {
|
|
261
|
+
config: expect.objectContaining({
|
|
186
262
|
initialTitle: "Meetings",
|
|
187
|
-
schema: {
|
|
188
|
-
properties: {
|
|
263
|
+
schema: expect.objectContaining({
|
|
264
|
+
properties: expect.objectContaining({
|
|
189
265
|
Name: { type: "title" },
|
|
190
266
|
"Meeting ID": { type: "text" },
|
|
191
267
|
"Meeting date": { type: "date" },
|
|
192
268
|
"Prep complete": { type: "checkbox" },
|
|
193
|
-
},
|
|
194
|
-
},
|
|
195
|
-
},
|
|
196
|
-
},
|
|
197
|
-
],
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
269
|
+
}),
|
|
270
|
+
}),
|
|
271
|
+
}),
|
|
272
|
+
}),
|
|
273
|
+
]),
|
|
274
|
+
);
|
|
275
|
+
expect(manifest.capabilities).toHaveLength(2);
|
|
276
|
+
expect(manifest.capabilities).toEqual(
|
|
277
|
+
expect.arrayContaining([
|
|
201
278
|
{
|
|
202
279
|
type: "sync",
|
|
203
280
|
key: "issues",
|
|
@@ -218,8 +295,8 @@ describe("buildApp", () => {
|
|
|
218
295
|
schedule: { type: "interval", intervalMs: 3_600_000 },
|
|
219
296
|
},
|
|
220
297
|
},
|
|
221
|
-
],
|
|
222
|
-
|
|
298
|
+
]),
|
|
299
|
+
);
|
|
223
300
|
});
|
|
224
301
|
|
|
225
302
|
it("emits a separate Notion-as-Code provisioning artifact", async () => {
|
|
@@ -285,6 +362,13 @@ describe("buildApp", () => {
|
|
|
285
362
|
resourceId: "__notion_apps_workspace__",
|
|
286
363
|
},
|
|
287
364
|
dataSources: [],
|
|
365
|
+
views: [
|
|
366
|
+
{
|
|
367
|
+
resourceId: "github-app-private-view",
|
|
368
|
+
type: "table",
|
|
369
|
+
dataSourceResourceId: "issues-source",
|
|
370
|
+
},
|
|
371
|
+
],
|
|
288
372
|
},
|
|
289
373
|
{
|
|
290
374
|
type: "database",
|
package/src/cli/build.ts
CHANGED
|
@@ -54,24 +54,29 @@ export async function buildApp(projectRoot: string): Promise<BuildResult> {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
const blockConfigs = await buildBlockDeclarations(projectRoot, capabilities);
|
|
57
|
-
const
|
|
58
|
-
const manifestPath = path.join(projectRoot, "dist", "manifest.json");
|
|
59
|
-
await fs.promises.writeFile(manifestPath, `${JSON.stringify(manifest, null, "\t")}\n`);
|
|
60
|
-
|
|
61
|
-
await emitProvisioningArtifact(
|
|
57
|
+
const entityProvisioning = await buildMetadata(
|
|
62
58
|
projectRoot,
|
|
63
59
|
capabilities.filter((capability) => capability.runtime === "worker"),
|
|
64
60
|
);
|
|
61
|
+
const manifest = await extractManifest(
|
|
62
|
+
bundlePath,
|
|
63
|
+
capabilities,
|
|
64
|
+
blockConfigs,
|
|
65
|
+
entityProvisioning,
|
|
66
|
+
);
|
|
67
|
+
const manifestPath = path.join(projectRoot, "dist", "manifest.json");
|
|
68
|
+
await fs.promises.writeFile(manifestPath, `${JSON.stringify(manifest, null, "\t")}\n`);
|
|
69
|
+
|
|
70
|
+
await emitProvisioningArtifact(projectRoot, entityProvisioning);
|
|
65
71
|
|
|
66
72
|
return { manifest, bundlePath, manifestPath };
|
|
67
73
|
}
|
|
68
74
|
|
|
69
|
-
/**
|
|
75
|
+
/** Emit the Notion-as-Code artifact recorded by the build-only metadata bundle. */
|
|
70
76
|
async function emitProvisioningArtifact(
|
|
71
77
|
projectRoot: string,
|
|
72
|
-
|
|
78
|
+
entityProvisioning: RecordedProvisioning,
|
|
73
79
|
): Promise<void> {
|
|
74
|
-
const entityProvisioning = await buildMetadata(projectRoot, capabilities);
|
|
75
80
|
const provisioningPath = path.join(projectRoot, "dist", "provisioning.json");
|
|
76
81
|
if (entityProvisioning.intents.length === 0) {
|
|
77
82
|
await fs.promises.rm(provisioningPath, { force: true });
|
package/src/cli/emit-manifest.ts
CHANGED
|
@@ -217,6 +217,7 @@ export async function extractManifest(
|
|
|
217
217
|
bundlePath: string,
|
|
218
218
|
capabilities: readonly DiscoveredCapability[],
|
|
219
219
|
blockConfigs: Record<string, unknown> = {},
|
|
220
|
+
provisioning?: RecordedProvisioning,
|
|
220
221
|
): Promise<AppManifest> {
|
|
221
222
|
clearRegisteredPacers();
|
|
222
223
|
const bundle = await importBundle(bundlePath);
|
|
@@ -267,8 +268,12 @@ export async function extractManifest(
|
|
|
267
268
|
const { database: _database, ...rest } = config;
|
|
268
269
|
config = { ...rest, databaseKey };
|
|
269
270
|
}
|
|
271
|
+
if (capability.type === "workflow") {
|
|
272
|
+
validateWorkflowAccessReferences(config.access, provisioning, capability.sourcePath);
|
|
273
|
+
}
|
|
270
274
|
|
|
271
275
|
assertJsonSafe(config, `${capability.sourcePath} config`);
|
|
276
|
+
|
|
272
277
|
return { type: capability.type, key: capability.key, config };
|
|
273
278
|
});
|
|
274
279
|
|
|
@@ -300,6 +305,108 @@ export async function extractManifest(
|
|
|
300
305
|
return manifest as AppManifest;
|
|
301
306
|
}
|
|
302
307
|
|
|
308
|
+
const WORKFLOW_ACCESS_TYPES: Record<string, true> = {
|
|
309
|
+
page: true,
|
|
310
|
+
database: true,
|
|
311
|
+
dataSource: true,
|
|
312
|
+
customAgent: true,
|
|
313
|
+
};
|
|
314
|
+
const WORKFLOW_ACCESS_LEVELS: Record<string, true> = {
|
|
315
|
+
view: true,
|
|
316
|
+
comment: true,
|
|
317
|
+
edit: true,
|
|
318
|
+
fullAccess: true,
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
function validateWorkflowAccessReferences(
|
|
322
|
+
access: unknown,
|
|
323
|
+
provisioning: RecordedProvisioning | undefined,
|
|
324
|
+
capabilityPath: string,
|
|
325
|
+
): void {
|
|
326
|
+
if (access === undefined) return;
|
|
327
|
+
if (
|
|
328
|
+
typeof access !== "object" ||
|
|
329
|
+
access === null ||
|
|
330
|
+
Array.isArray(access) ||
|
|
331
|
+
(provisioning === undefined && Object.keys(access).length > 0)
|
|
332
|
+
) {
|
|
333
|
+
throw new Error(
|
|
334
|
+
`${capabilityPath}: workflow access must reference resources recorded by the current build`,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
if (provisioning === undefined) {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const accessObject = access as Record<string, unknown>;
|
|
342
|
+
const aliases = Object.keys(accessObject);
|
|
343
|
+
if (Reflect.ownKeys(accessObject).length !== aliases.length) {
|
|
344
|
+
throw new Error(`${capabilityPath}: workflow access aliases must be enumerable strings`);
|
|
345
|
+
}
|
|
346
|
+
for (const alias of aliases) {
|
|
347
|
+
if (
|
|
348
|
+
!/^[a-zA-Z][a-zA-Z0-9_-]{0,127}$/.test(alias) ||
|
|
349
|
+
alias === "__proto__" ||
|
|
350
|
+
alias === "prototype" ||
|
|
351
|
+
alias === "constructor"
|
|
352
|
+
) {
|
|
353
|
+
throw new Error(`${capabilityPath}: invalid workflow access alias "${alias}"`);
|
|
354
|
+
}
|
|
355
|
+
const requirement = accessObject[alias];
|
|
356
|
+
if (
|
|
357
|
+
typeof requirement !== "object" ||
|
|
358
|
+
requirement === null ||
|
|
359
|
+
Array.isArray(requirement) ||
|
|
360
|
+
Reflect.ownKeys(requirement).length !== 3 ||
|
|
361
|
+
!Object.hasOwn(requirement, "type") ||
|
|
362
|
+
!Object.hasOwn(requirement, "resourceId") ||
|
|
363
|
+
!Object.hasOwn(requirement, "level")
|
|
364
|
+
) {
|
|
365
|
+
throw new Error(`${capabilityPath}: invalid workflow access requirement "${alias}"`);
|
|
366
|
+
}
|
|
367
|
+
const requirementObject = requirement as Record<string, unknown>;
|
|
368
|
+
const type = requirementObject.type;
|
|
369
|
+
const resourceId = requirementObject.resourceId;
|
|
370
|
+
const level = requirementObject.level;
|
|
371
|
+
if (
|
|
372
|
+
typeof type !== "string" ||
|
|
373
|
+
!Object.hasOwn(WORKFLOW_ACCESS_TYPES, type) ||
|
|
374
|
+
typeof resourceId !== "string" ||
|
|
375
|
+
resourceId.length === 0 ||
|
|
376
|
+
typeof level !== "string" ||
|
|
377
|
+
(type === "customAgent"
|
|
378
|
+
? level !== "call"
|
|
379
|
+
: !Object.hasOwn(WORKFLOW_ACCESS_LEVELS, level))
|
|
380
|
+
) {
|
|
381
|
+
throw new Error(`${capabilityPath}: invalid workflow access requirement "${alias}"`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (
|
|
385
|
+
!provisioning.intents.some((intent) => {
|
|
386
|
+
switch (type) {
|
|
387
|
+
case "page":
|
|
388
|
+
return intent.type === "page" && intent.resourceId === resourceId;
|
|
389
|
+
case "database":
|
|
390
|
+
return intent.type === "database" && intent.resourceId === resourceId;
|
|
391
|
+
case "customAgent":
|
|
392
|
+
return intent.type === "custom_agent" && intent.resourceId === resourceId;
|
|
393
|
+
case "dataSource":
|
|
394
|
+
return (
|
|
395
|
+
intent.type === "database" &&
|
|
396
|
+
(intent.dataSources ?? []).some(
|
|
397
|
+
(dataSource) => dataSource.resourceId === resourceId,
|
|
398
|
+
)
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
})
|
|
402
|
+
) {
|
|
403
|
+
throw new Error(
|
|
404
|
+
`${capabilityPath}: workflow access "${alias}" references ${type} "${resourceId}" that is not declared by the current build`,
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
303
410
|
/** Import the metadata bundle and return the provisioning declarations it recorded. */
|
|
304
411
|
export async function extractProvisioning(bundlePath: string): Promise<RecordedProvisioning> {
|
|
305
412
|
const bundle = await importBundle(bundlePath);
|
package/src/index.ts
CHANGED
|
@@ -5,4 +5,24 @@ export { page } from "./notion-as-code/page.js";
|
|
|
5
5
|
export { teamspace } from "./notion-as-code/teamspace.js";
|
|
6
6
|
export { view } from "./notion-as-code/view.js";
|
|
7
7
|
export { sync } from "./sync.js";
|
|
8
|
-
export {
|
|
8
|
+
export {
|
|
9
|
+
workflow,
|
|
10
|
+
type Workflow,
|
|
11
|
+
type WorkflowAccess,
|
|
12
|
+
type WorkflowAccessAgentLevel,
|
|
13
|
+
type WorkflowAccessBindings,
|
|
14
|
+
type WorkflowAccessDeclaration,
|
|
15
|
+
type WorkflowAccessDeclarations,
|
|
16
|
+
type WorkflowAccessLevel,
|
|
17
|
+
type WorkflowAccessLevelFor,
|
|
18
|
+
type WorkflowAccessResource,
|
|
19
|
+
type WorkflowAccessResourceLevel,
|
|
20
|
+
type WorkflowAccessResourceType,
|
|
21
|
+
type WorkflowAccessRequirement,
|
|
22
|
+
type WorkflowAccessRequirements,
|
|
23
|
+
type WorkflowAccessValue,
|
|
24
|
+
type WorkflowConfiguration,
|
|
25
|
+
type WorkflowContext,
|
|
26
|
+
type WorkflowTriggerConfiguration,
|
|
27
|
+
} from "./workflow.js";
|
|
28
|
+
export type { WorkflowState, WorkflowStateValue } from "./workflow-state.js";
|
|
@@ -14,6 +14,7 @@ export type CustomAgentArgs = {
|
|
|
14
14
|
};
|
|
15
15
|
|
|
16
16
|
export type CustomAgentHandle = {
|
|
17
|
+
readonly resourceType: "customAgent";
|
|
17
18
|
readonly resourceId: string;
|
|
18
19
|
};
|
|
19
20
|
|
|
@@ -26,5 +27,5 @@ export function customAgent(args: CustomAgentArgs): CustomAgentHandle {
|
|
|
26
27
|
recordIntent({ type: "custom_agent", ...args } satisfies {
|
|
27
28
|
type: "custom_agent";
|
|
28
29
|
} & CustomAgentIntent);
|
|
29
|
-
return { resourceId: args.resourceId };
|
|
30
|
+
return { resourceType: "customAgent", resourceId: args.resourceId };
|
|
30
31
|
}
|