@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.
Files changed (67) hide show
  1. package/AGENTS.md +27 -0
  2. package/README.md +77 -35
  3. package/dist/cli/build.d.ts.map +1 -1
  4. package/dist/cli/build.js +12 -7
  5. package/dist/cli/emit-manifest.d.ts +1 -1
  6. package/dist/cli/emit-manifest.d.ts.map +1 -1
  7. package/dist/cli/emit-manifest.js +66 -1
  8. package/dist/index.d.ts +2 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +3 -1
  11. package/dist/notion-as-code/custom-agent.d.ts +1 -0
  12. package/dist/notion-as-code/custom-agent.d.ts.map +1 -1
  13. package/dist/notion-as-code/custom-agent.js +1 -1
  14. package/dist/notion-as-code/database.d.ts +90 -48
  15. package/dist/notion-as-code/database.d.ts.map +1 -1
  16. package/dist/notion-as-code/database.js +135 -45
  17. package/dist/notion-as-code/database.test.d.ts +2 -0
  18. package/dist/notion-as-code/database.test.d.ts.map +1 -0
  19. package/dist/notion-as-code/index.d.ts +2 -2
  20. package/dist/notion-as-code/index.d.ts.map +1 -1
  21. package/dist/notion-as-code/intents.d.ts +34 -2
  22. package/dist/notion-as-code/intents.d.ts.map +1 -1
  23. package/dist/notion-as-code/page.d.ts +1 -0
  24. package/dist/notion-as-code/page.d.ts.map +1 -1
  25. package/dist/notion-as-code/page.js +1 -0
  26. package/dist/notion-as-code/schema.d.ts +8 -4
  27. package/dist/notion-as-code/schema.d.ts.map +1 -1
  28. package/dist/notion-as-code/views.d.ts +29 -10
  29. package/dist/notion-as-code/views.d.ts.map +1 -1
  30. package/dist/sync.d.ts +33 -15
  31. package/dist/sync.d.ts.map +1 -1
  32. package/dist/sync.js +12 -0
  33. package/dist/workflow-access-types.test.d.ts +2 -0
  34. package/dist/workflow-access-types.test.d.ts.map +1 -0
  35. package/dist/workflow-access.d.ts +68 -0
  36. package/dist/workflow-access.d.ts.map +1 -0
  37. package/dist/workflow-access.js +135 -0
  38. package/dist/workflow-state.d.ts +13 -0
  39. package/dist/workflow-state.d.ts.map +1 -0
  40. package/dist/workflow-state.js +107 -0
  41. package/dist/workflow.d.ts +65 -9
  42. package/dist/workflow.d.ts.map +1 -1
  43. package/dist/workflow.js +119 -5
  44. package/docs/BUILD.md +134 -4
  45. package/package.json +1 -1
  46. package/skills/notion-as-code/SKILL.md +89 -49
  47. package/skills/sync/SKILL.md +21 -18
  48. package/skills/workflow/SKILL.md +101 -17
  49. package/src/cli/build.test.ts +148 -64
  50. package/src/cli/build.ts +13 -8
  51. package/src/cli/emit-manifest.ts +107 -0
  52. package/src/index.ts +21 -1
  53. package/src/notion-as-code/custom-agent.ts +2 -1
  54. package/src/notion-as-code/database.test.ts +661 -0
  55. package/src/notion-as-code/database.ts +349 -127
  56. package/src/notion-as-code/index.ts +16 -1
  57. package/src/notion-as-code/intents.ts +39 -2
  58. package/src/notion-as-code/page.ts +2 -0
  59. package/src/notion-as-code/schema.ts +11 -3
  60. package/src/notion-as-code/views.ts +29 -10
  61. package/src/sync.test.ts +295 -0
  62. package/src/sync.ts +85 -21
  63. package/src/workflow-access-types.test.ts +69 -0
  64. package/src/workflow-access.ts +299 -0
  65. package/src/workflow-state.ts +152 -0
  66. package/src/workflow.test.ts +441 -1
  67. package/src/workflow.ts +230 -13
package/dist/workflow.js CHANGED
@@ -3,6 +3,10 @@ import {
3
3
  normalizeConnectionRequirements,
4
4
  validateTriggerConnections
5
5
  } from "./connections.js";
6
+ import {
7
+ hydrateWorkflowAccess,
8
+ normalizeWorkflowAccess
9
+ } from "./workflow-access.js";
6
10
  import { createHash } from "node:crypto";
7
11
  import { readFile } from "node:fs/promises";
8
12
  import { join } from "node:path";
@@ -12,11 +16,14 @@ import { writeOutput } from "./output.js";
12
16
  import { resolveRuntimeInput } from "./runtime-input.js";
13
17
  import { readRunMetadata } from "./runtime-metadata.js";
14
18
  import { createWorkflowTriggers } from "./triggers.generated.js";
19
+ import { createWorkflowStepState } from "./workflow-state.js";
20
+ const MAX_WORKFLOW_WAIT_MS = 7 * 24 * 60 * 60 * 1e3;
15
21
  import {
16
22
  connections
17
23
  } from "./connections.js";
18
24
  function workflow(configuration) {
19
25
  const requirements = normalizeConnectionRequirements(configuration.connections ?? {});
26
+ const accessRequirements = normalizeWorkflowAccess(configuration.access);
20
27
  const connectionDeclarations = configuration.connections === void 0 ? void 0 : structuredClone(configuration.connections);
21
28
  const triggers = typeof configuration.triggers === "function" ? configuration.triggers({ triggers: createWorkflowTriggers() }) : configuration.triggers;
22
29
  validateTriggerConnections(triggers, requirements);
@@ -26,21 +33,25 @@ function workflow(configuration) {
26
33
  name: configuration.name,
27
34
  description: configuration.description,
28
35
  triggers,
29
- ...configuration.connections === void 0 ? {} : { connections: requirements }
36
+ ...configuration.connections === void 0 ? {} : { connections: requirements },
37
+ ...configuration.access === void 0 ? {} : { access: accessRequirements }
30
38
  },
31
39
  async handler(event, options) {
32
40
  try {
33
41
  event = await resolveRuntimeInput(event);
34
42
  const runMetadata = readRunMetadata();
35
43
  const baseContext = createCapabilityContext();
44
+ const step = createStep(runMetadata);
36
45
  const capabilityContext = {
37
46
  ...baseContext,
47
+ access: hydrateWorkflowAccess(accessRequirements),
38
48
  connections: createWorkflowConnections(
39
49
  baseContext.notion,
40
50
  connectionDeclarations
41
51
  ),
42
52
  ...runMetadata,
43
- step: createStep(runMetadata)
53
+ step,
54
+ wait: createWait(step)
44
55
  };
45
56
  await configuration.handler(event, capabilityContext);
46
57
  if (options?.concreteOutput) {
@@ -48,6 +59,16 @@ function workflow(configuration) {
48
59
  }
49
60
  writeOutput({ _tag: "success", value: { status: "success" } });
50
61
  } catch (err) {
62
+ if (err instanceof WorkflowWaitInterrupt) {
63
+ if (options?.concreteOutput) {
64
+ return err.result;
65
+ }
66
+ writeOutput({
67
+ _tag: "wait",
68
+ wait: err.result.wait
69
+ });
70
+ return;
71
+ }
51
72
  const error = new ExecutionError(err);
52
73
  if (!options?.concreteOutput) {
53
74
  writeOutput({
@@ -81,7 +102,8 @@ function writeStepEvent(event, value, startedAt, emittedAt = performance.now())
81
102
  `
82
103
  );
83
104
  }
84
- function createStep({ runGroupId }) {
105
+ function createStep(metadata) {
106
+ const { runGroupId } = metadata;
85
107
  const stepNameByKey = /* @__PURE__ */ new Map();
86
108
  async function step(name, optionsOrFn, maybeFn) {
87
109
  const options = typeof optionsOrFn === "function" ? void 0 : optionsOrFn;
@@ -98,7 +120,9 @@ function createStep({ runGroupId }) {
98
120
  );
99
121
  }
100
122
  stepNameByKey.set(key, name);
101
- const context = { id: `${runGroupId}:${key}` };
123
+ const id = `${runGroupId}:${key}`;
124
+ const stepState = createWorkflowStepState(metadata, id);
125
+ const context = { id, state: stepState.state };
102
126
  const event = { id: context.id, name, key };
103
127
  const startedAt = performance.now();
104
128
  writeStepEvent("started", event, startedAt, startedAt);
@@ -124,7 +148,7 @@ function createStep({ runGroupId }) {
124
148
  );
125
149
  return checkpoint.value;
126
150
  }
127
- const value = normalizeWorkflowStepResult(await fn(context));
151
+ const value = normalizeWorkflowStepResult(await stepState.run(() => fn(context)));
128
152
  writeStepEvent("success", { ...event, value }, startedAt);
129
153
  writeStepEvent(
130
154
  "completed",
@@ -162,6 +186,96 @@ function createStep({ runGroupId }) {
162
186
  }
163
187
  return step;
164
188
  }
189
+ class WorkflowWaitInterrupt extends Error {
190
+ constructor(result) {
191
+ super(`Workflow is waiting until ${new Date(result.wait.resumeAtMs).toISOString()}`);
192
+ this.result = result;
193
+ this.name = "WorkflowWaitInterrupt";
194
+ }
195
+ result;
196
+ }
197
+ function createWait(step) {
198
+ return {
199
+ async until(name, options) {
200
+ const keyInput = options.key ?? name;
201
+ const keySegments = [
202
+ "wait",
203
+ "until",
204
+ ...typeof keyInput === "string" ? [keyInput] : keyInput
205
+ ];
206
+ const resumeAtMs = await step(
207
+ name,
208
+ { key: keySegments },
209
+ () => resolveWaitUntilMs(options)
210
+ );
211
+ if (Date.now() >= resumeAtMs) {
212
+ return;
213
+ }
214
+ throw new WorkflowWaitInterrupt({
215
+ status: "waiting",
216
+ wait: {
217
+ type: "until",
218
+ name,
219
+ resumeAtMs
220
+ }
221
+ });
222
+ }
223
+ };
224
+ }
225
+ function resolveWaitUntilMs(options) {
226
+ const now = Date.now();
227
+ if ("at" in options) {
228
+ const resumeAtMs = options.at.getTime();
229
+ if (!Number.isFinite(resumeAtMs)) {
230
+ throw new Error("Workflow wait date must be valid.");
231
+ }
232
+ assertWaitFitsCheckpointRetention(resumeAtMs - now);
233
+ return resumeAtMs;
234
+ }
235
+ const durationMs = workflowWaitDurationMs(options.after);
236
+ assertWaitFitsCheckpointRetention(durationMs);
237
+ return now + durationMs;
238
+ }
239
+ function workflowWaitDurationMs(duration) {
240
+ const entries = Object.entries(duration);
241
+ if (entries.length === 0) {
242
+ throw new Error("Workflow wait duration must specify at least one unit.");
243
+ }
244
+ let durationMs = 0;
245
+ for (const [unit, value] of entries) {
246
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0) {
247
+ throw new Error("Workflow wait duration values must be positive finite integers.");
248
+ }
249
+ durationMs += value * waitDurationUnitMultiplier(unit);
250
+ if (!Number.isSafeInteger(durationMs)) {
251
+ throw new Error("Workflow wait duration is too large.");
252
+ }
253
+ }
254
+ return durationMs;
255
+ }
256
+ function assertWaitFitsCheckpointRetention(durationMs) {
257
+ if (durationMs >= MAX_WORKFLOW_WAIT_MS) {
258
+ throw new Error("Workflow waits must be shorter than 7 days.");
259
+ }
260
+ }
261
+ function waitDurationUnitMultiplier(unit) {
262
+ switch (unit) {
263
+ case "milliseconds":
264
+ return 1;
265
+ case "seconds":
266
+ return 1e3;
267
+ case "minutes":
268
+ return 6e4;
269
+ case "hours":
270
+ return 36e5;
271
+ case "days":
272
+ return 864e5;
273
+ case "weeks":
274
+ return 6048e5;
275
+ default:
276
+ throw new Error(`Unsupported workflow wait duration unit: ${unit}`);
277
+ }
278
+ }
165
279
  function createWorkflowStepKey(segments) {
166
280
  if (segments.length === 0) {
167
281
  throw new Error("Workflow step keys must contain at least one segment.");
package/docs/BUILD.md CHANGED
@@ -20,6 +20,7 @@ corresponding capability. The filename becomes its key:
20
20
  ```text
21
21
  my-app/
22
22
  ├── src/
23
+ │ ├── notion.ts
23
24
  │ ├── workflows/
24
25
  │ │ └── onPageCreated.ts
25
26
  │ └── lib/
@@ -32,21 +33,117 @@ my-app/
32
33
 
33
34
  Files elsewhere under `src/` are ordinary modules and enter the build only when imported
34
35
  by a capability. Capability discovery does not recurse into subdirectories.
36
+ Notion-as-Code declarations may be inline or in an imported helper. Use a local
37
+ module such as `src/workflows/myWorkflow/lib/notion.ts` for resources owned by one
38
+ capability, and `src/notion.ts` for app-wide declarations or resources without a
39
+ natural capability owner.
35
40
 
36
41
  ## Pipeline
37
42
 
38
43
  1. Discover top-level files in the capability directories.
39
44
  2. Generate `.notion/entry.ts` with workflow and sync imports and a dispatcher.
40
45
  3. Bundle the app's code into `dist/worker.js`, leaving npm packages external.
41
- 4. Evaluate custom-block declarations separately, validate capability exports and
42
- configuration, and write `dist/manifest.json`. Blocks do not enter `worker.js`.
46
+ 4. Evaluate custom-block declarations separately. Blocks do not enter `worker.js`.
43
47
  5. Evaluate worker capabilities in a separate build-only metadata bundle to record
44
- Notion-as-Code declarations, then emit `dist/provisioning.json` if there are any.
48
+ Notion-as-Code declarations. Validate workflow access references against those
49
+ declarations before writing the manifest and optional provisioning artifact.
50
+ 6. Emit `dist/manifest.json` and, when declarations exist, `dist/provisioning.json`.
45
51
 
46
52
  Capability modules must therefore be importable without secrets or network access.
47
53
  Read required environment variables and make requests inside handlers or workflow
48
54
  steps, not at module scope.
49
55
 
56
+ ## Declaring Notion-as-Code databases
57
+
58
+ Database declarations use explicit resource IDs and keyed schemas. For a single
59
+ data source, supply the database ID as the first argument and the distinct data
60
+ source ID in `dataSourceResourceId`:
61
+
62
+ ```ts
63
+ import { notion } from "@notionhq/apps/notion-as-code";
64
+
65
+ const tasks = notion.database("tasks-db", {
66
+ dataSourceResourceId: "tasks-source",
67
+ name: "Tasks",
68
+ schema: {
69
+ Name: { type: "title", resourceId: "tasks-name" },
70
+ Effort: { type: "text", resourceId: "tasks-effort" },
71
+ },
72
+ });
73
+ ```
74
+
75
+ Here `name` is the single-source shorthand: it supplies both the database and
76
+ data-source display names. The source handle is `tasks.dataSource`; pass it to
77
+ `sync({ dataSource, ... })` from `@notionhq/apps`.
78
+
79
+ For multiple sources, use `datasources` instead of the top-level `schema`:
80
+
81
+ ````ts
82
+ const work = notion.database("work-db", {
83
+ name: "Work",
84
+ datasources: {
85
+ Tasks: {
86
+ resourceId: "work-tasks-source",
87
+ schema: {
88
+ Name: { type: "title", resourceId: "work-tasks-name" },
89
+ Effort: { type: "text", resourceId: "work-tasks-effort" },
90
+ },
91
+ },
92
+ Projects: {
93
+ resourceId: "work-projects-source",
94
+ schema: {
95
+ Name: { type: "title", resourceId: "work-projects-name" },
96
+ },
97
+ },
98
+ },
99
+ });
100
+
101
+ The keys in `datasources` are the source names, so the handles are indexed by
102
+ those names: `work.datasources.Tasks` and `work.datasources.Projects`. Nested
103
+ data-source configs do not accept `name`. The top-level database `name` remains
104
+ supported and names the database; in a single-source declaration it also
105
+ supplies the source display name. Property configs do not accept `name`; each
106
+ schema key is always the property's name. Typed property access uses
107
+ `work.datasources.Tasks.schema.Effort`; sync primary keys and row properties use
108
+ the string/object key `"Effort"`.
109
+
110
+ Every database, source, and property ID remains explicit and must be unique
111
+ across the provisioning declarations. No IDs are generated from keys or names.
112
+ Preserve explicit IDs when changing authoring keys or display names.
113
+
114
+ Page and teamspace handles accept the same forms through
115
+ `page.addDatabase("database-id", args)` and `teamspace.addDatabase("database-id", args)`.
116
+ Top-level declarations also accept
117
+ `parent: { type: "resourceId", resourceId: "parent-id" }`; omitting it retains the
118
+ private Apps workspace parent. A database must declare at least one data source
119
+ or at least one view. A linked-only declaration may omit `datasources`, or use
120
+ `datasources: {}`, only with a nonempty `views` list. The empty forms `{}`,
121
+ `{ datasources: {} }`, and `{ datasources: {}, views: [] }` are rejected; widened
122
+ or dynamic maps and arrays are checked at runtime. The single-source `schema` and
123
+ multi-source `datasources` forms cannot be combined.
124
+
125
+ Here is a linked-only database whose typed table view references an explicit
126
+ data source resource ID:
127
+
128
+ ```ts
129
+ const linked = notion.database("work-linked-db", {
130
+ views: [
131
+ {
132
+ resourceId: "work-issues-table",
133
+ type: "table",
134
+ dataSourceResourceId: "issues-source",
135
+ properties: [{ property: "issue-title", visible: true }],
136
+ },
137
+ ],
138
+ });
139
+ ````
140
+
141
+ The build converts these declarations to the existing serialized database
142
+ intents: `dataSources` and `properties` remain arrays with exact resource IDs
143
+ and names from the authoring keys. Authoring-only `schema`, `datasources`, and
144
+ database-level `dataSourceResourceId` fields do not leak into the serialized
145
+ database intent. The provisioning JSON envelope and server API are unchanged.
146
+
50
147
  ## Provisioning artifact and deployment
51
148
 
52
149
  The optional provisioning artifact uses `$schema: "notion:apps-provisioning:v1"`,
@@ -74,7 +171,8 @@ uploaded project must declare its SDK dependency and a `build` script that invok
74
171
  Worker using `workersCreateWorker` with `createApp: true`, or updates the existing
75
172
  Worker, then uploads source and calls `workersBuildWorker` with the Worker ID.
76
173
  For App-linked Workers, the build endpoint coordinates capability registration,
77
- Notion-as-Code provisioning, and database attachment before reporting build success.
174
+ Notion-as-Code provisioning, database attachment, workflow binding resolution,
175
+ and workflow permission reconciliation before reporting build success.
78
176
  It returns the normal build result and run ID, not provisioning state. No separate
79
177
  Apps deployment endpoint is required.
80
178
 
@@ -97,6 +195,9 @@ flow and local state, and reconciles attachments. It reads the optional
97
195
  `dist/provisioning.json` locally and does not upload it separately or invoke
98
196
  the cloud build endpoint.
99
197
 
198
+ Workflows with nonempty `access` require cloud deployment. The local-build path
199
+ rejects them after the SDK build and before uploading or deploying the bundle.
200
+
100
201
  Resource bindings are not stored in SDK build output. Local deployments retain a
101
202
  local state file and report `state_file`; cloud deployments keep installation-owned
102
203
  resource mappings and state on the server. Cloud builds do not import local state
@@ -106,6 +207,35 @@ switching between modes may recreate resources rather than reuse existing bindin
106
207
  Cloud failures never automatically retry locally. There is no rollback: code or
107
208
  resources may already have changed when a deployment fails.
108
209
 
210
+ ## Workflow access
211
+
212
+ `workflow({ access: { handbook: { resource: handbook, level: "view" } }, ... })`
213
+ declares access to a NaC resource and a named runtime binding. The manifest
214
+ contains only the alias and its symbolic `{ type, resourceId, level }`
215
+ requirement; live IDs are not build output.
216
+ Build validation rejects references missing from the current provisioning
217
+ declarations or having the wrong resource kind. `resourceId` is required because
218
+ it identifies the declaration used for this validation and binding.
219
+
220
+ After provisioning, cloud deployment resolves requirements against the successful
221
+ NaC apply result. It reconciles code-managed grants through the existing
222
+ Notion-module permissions and saves those permissions together with resolved
223
+ bindings on the workflow's worker module, keyed by capability and alias, in one
224
+ workflow transaction. Bindings are not authorization: the existing permissions
225
+ authorize `context.notion` calls. Redeployment updates or removes code-managed
226
+ grants as declarations change while preserving manually configured UI grants.
227
+
228
+ Execution reads the selected workflow configuration, not the latest NaC state,
229
+ validates the bindings against the capability requirements, and injects only that
230
+ capability's declared bindings. The SDK exposes typed, readonly `{ type, id }`
231
+ entries in `context.access`; UI-granted resources do not appear there. Use live
232
+ record IDs directly in `context.notion` calls for UI-granted resources; those
233
+ calls remain subject to the existing permissions. Missing or mismatched bindings
234
+ fail before the handler executes.
235
+
236
+ See the [workflow skill](../skills/workflow/SKILL.md#resources-created-with-the-app)
237
+ for supported resource types, access levels, and an authoring example.
238
+
109
239
  ## Manifest
110
240
 
111
241
  The workflow-only manifest retains the platform's existing resource fields as empty arrays:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@notionhq/apps",
3
- "version": "0.0.17",
3
+ "version": "0.0.19",
4
4
  "description": "An SDK for building workflow apps for Notion",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -17,12 +17,20 @@ Import the resource creators you use, such as
17
17
  resources for deployment; they do not make Notion API requests when called.
18
18
  Use `context.notion` for runtime API operations instead.
19
19
 
20
- Put shared declarations in `src/lib/`, or capability-specific declarations in
21
- a directory such as `src/syncs/lib/`. Import them from a workflow or sync
22
- module so the build's metadata evaluation reaches them. An unimported resource
23
- file is not discovered automatically. Declare resources at module scope, not
24
- inside handlers or durable steps. Module evaluation must work without
25
- credentials or network access.
20
+ Declare resources at module scope, not inside handlers or durable steps. Keep a
21
+ small declaration inline when it belongs to one capability. For a larger set
22
+ owned by one capability, use a local module such as
23
+ `src/workflows/myWorkflow/lib/notion.ts`. Use `src/notion.ts` when declarations
24
+ are shared across capabilities or do not have a natural capability owner. Module
25
+ evaluation must work without credentials or network access.
26
+
27
+ To configure a workflow's access to a declared page, database, data source, or
28
+ custom agent, pass its handle in `access: { alias: { resource: handle, level } }`.
29
+ The handler receives the live identity as `context.access.alias.id`; importing
30
+ a declaration alone does not request access. See the
31
+ [workflow skill](../workflow/SKILL.md#resources-created-with-the-app) for levels
32
+ and examples. Nonempty workflow `access` requires cloud deployment; `--local-build`
33
+ rejects it before upload.
26
34
 
27
35
  ## Where declarations are picked up
28
36
 
@@ -33,24 +41,27 @@ same rule. Their resource type does not determine their file location.
33
41
 
34
42
  ```text
35
43
  src/
36
- lib/
37
- resources.ts Shared page/database declarations
44
+ notion.ts App-wide resource declarations
38
45
  syncs/
39
- issues.ts Default-exported sync; imports ../lib/resources
40
- lib/
41
- issueSchema.ts Sync-specific helper; must be imported
46
+ issues.ts Default-exported sync; imports ../notion.js
42
47
  workflows/
43
- notify.ts Default-exported workflow
48
+ notify.ts May declare a small related resource inline
49
+ myWorkflow.ts Imports ./myWorkflow/lib/notion.js
50
+ myWorkflow/
51
+ lib/
52
+ notion.ts Resources owned by myWorkflow
44
53
  ```
45
54
 
46
- For example, export a database handle from `src/lib/resources.ts` and import it
47
- in `src/syncs/issues.ts` for `sync`. A page-only declaration
48
- module can be loaded with a side-effect import such as
49
- `import "../lib/pages"` from a workflow or sync. Keep those imports at module
50
- scope so build evaluation runs the declarations.
55
+ Choose the location based on ownership, not resource type. Export an app-wide
56
+ database handle from `src/notion.ts` and import it in `src/syncs/issues.ts` when
57
+ the sync uses it. A declaration-only module can be loaded with a side-effect
58
+ import. Keep these imports at module scope so build evaluation runs the
59
+ declarations.
51
60
 
52
- There is no automatically scanned `src/pages/`, `src/databases/`, or Notion as Code
53
- entrypoint. Helpers under `src/syncs/lib/` are not discovered on their own.
61
+ Resource helper modules, including `src/notion.ts`, are not automatically scanned.
62
+ There is no automatically scanned `src/pages/` or `src/databases/` directory
63
+ either. Helpers under capability-specific `lib/` directories are not discovered
64
+ on their own.
54
65
  Every direct file in a capability directory must still default-export that
55
66
  capability, so do not put a resource-only file there. Imports reached only
56
67
  through `src/customBlocks/` or browser code do not enter the Notion as Code recording
@@ -63,12 +74,14 @@ Read the installed Notion as Code types before choosing fields. The Apps
63
74
  root exports support:
64
75
 
65
76
  - `teamspace({ resourceId, name, accessLevel })`, with `addPage` and
66
- `addDatabase` on the returned handle.
77
+ `teamspace.addDatabase(id, args)` on the returned handle.
67
78
  - `page({ resourceId, parent?, properties?, content? })`, with
68
- `addPage` and `addDatabase` for children.
69
- - `database({ resourceId, parent?, name?, dataSources? })`, returning
70
- data source handles indexed by their resource IDs. A data source's
71
- `addPage` declares a row; the database handle also exposes `addView`.
79
+ `addPage` and `page.addDatabase(id, args)` for children.
80
+ - `database(resourceId, { dataSourceResourceId, name, schema, parent?, views? })`,
81
+ returning a single source as `handle.dataSource`, or
82
+ `database(resourceId, { datasources: { Key: { resourceId, schema } }, views?, parent? })`,
83
+ returning sources as `handle.datasources.Key`. A source's `addPage` declares a row;
84
+ the database handle also exposes `addView`.
72
85
  - `customAgent({ resourceId, name, instructions?, sharedResources? })`.
73
86
  Shared resources are declared resource IDs; inspect the installed types
74
87
  before specifying models or triggers.
@@ -77,56 +90,71 @@ root exports support:
77
90
  Use property resource IDs in view filters, sorts, and layout options; calendar
78
91
  views require `calendarBy`, and timeline views require `timelineBy`.
79
92
 
93
+ A database declaration must include at least one data source or at least one
94
+ view. A linked-only database may omit `datasources`, or use `datasources: {}`,
95
+ only when `views` is nonempty. `{}`, `{ datasources: {} }`, and
96
+ `{ datasources: {}, views: [] }` are invalid; widened or dynamic maps and arrays
97
+ are checked at runtime as well as by the type-level forms.
98
+
99
+ ```ts
100
+ const linkedIssues = database("project-issues-linked", {
101
+ views: [
102
+ {
103
+ resourceId: "project-issues-table",
104
+ type: "table",
105
+ dataSourceResourceId: "issues-source",
106
+ properties: [{ property: "issue-title", visible: true }],
107
+ },
108
+ ],
109
+ });
110
+ ```
111
+
112
+ The table view above is typed by its `type: "table"` schema and links to the
113
+ explicit `issues-source` resource ID.
114
+
80
115
  Keep resource IDs stable across builds. They identify declarations, not live
81
116
  Notion UUIDs. Avoid the reserved `__notion_apps_` prefix. Explicit parents
82
117
  use `{ type: "resourceId", resourceId: parent.resourceId }`; child helpers set
83
118
  this reference for you. Pages and databases without a parent default to private
84
119
  top-level resources in the Apps workspace.
85
120
 
86
- Several fields, including database covers, page layouts, and custom-agent
87
- triggers, still have incomplete types. A field typed as `unknown` is not proof
121
+ Several fields, including database covers and custom-agent triggers,
122
+ still have incomplete types. A field typed as `unknown` is not proof
88
123
  that any payload is supported. Check implementation and examples before using
89
124
  it; do not assume parity with other Notion as Code packages. Database views are
90
125
  typed with `ViewSchema`; standalone declarations accept `ViewArgs`, which adds
91
126
  the owning `databaseResourceId`.
92
127
 
93
128
  The Apps SDK exposes a subset of Notion as Code. Data sources are nested inside
94
- `database({ dataSources: [...] })`, not declared by a separate
129
+ `database(resourceId, { datasources: { ... } })`, not declared by a separate
95
130
  `dataSource` function. It has no `space` workspace declaration:
96
131
  Apps deployment rejects workspace creation or changes and supplies the App's
97
132
  workspace binding itself. Value helpers and types stay on their existing
98
133
  subpaths. For example, import `{ notion }` from `@notionhq/apps/notion-as-code`
99
134
  for `notion.text(...)` or `notion.file(resourceId)`. The latter creates a file
100
135
  reference, not an upload or file declaration. These helpers are not root exports.
101
- CLI acceptance of
102
- an intent envelope alone does not establish server support for its contents.
136
+ CLI acceptance of a serialized intent envelope alone does not establish server
137
+ support for its contents.
103
138
 
104
139
  ## Use a declared data source in a sync
105
140
 
106
- This example can live directly in `src/syncs/issues.ts`. Move the resource
107
- declaration into an imported helper when sharing it with other capabilities.
141
+ This sync-specific example declares the resource inline in `src/syncs/issues.ts`:
108
142
 
109
143
  ```ts
110
144
  import { database, sync } from "@notionhq/apps";
111
145
  import { Builder } from "@notionhq/apps/builder";
112
146
 
113
- const issues = database({
114
- resourceId: "issues-db",
147
+ const issues = database("issues-db", {
148
+ dataSourceResourceId: "issues-source",
115
149
  name: "Issues",
116
- dataSources: [
117
- {
118
- resourceId: "issues-source",
119
- name: "Issues",
120
- properties: [
121
- { resourceId: "issue-name", name: "Name", type: "title" },
122
- { resourceId: "issue-id", name: "External ID", type: "text" },
123
- ],
124
- },
125
- ],
150
+ schema: {
151
+ Name: { resourceId: "issue-name", type: "title" },
152
+ "External ID": { resourceId: "issue-id", type: "text" },
153
+ },
126
154
  });
127
155
 
128
156
  export default sync({
129
- dataSource: issues.dataSources["issues-source"],
157
+ dataSource: issues.dataSource,
130
158
  primaryKey: "External ID",
131
159
  mode: "incremental",
132
160
  handler: async () => ({
@@ -144,11 +172,23 @@ export default sync({
144
172
 
145
173
  Pass a data source handle, not the whole database handle or a UUID. The SDK
146
174
  derives the sync schema from that handle. `primaryKey` names a title or text
147
- property by its display name, not its resource ID. Omit that property from
148
- upsert values; the SDK fills it from `key`. Use Apps `Builder` for sync values.
149
- Follow the [sync skill](../sync/SKILL.md) for pagination and reconciliation.
150
-
151
- Notion as Code properties are an array with resource IDs and names.
175
+ property by its schema key, which is also its serialized property name, not its
176
+ resource ID. Omit that property from upsert values; the SDK fills it from `key`.
177
+ Use Apps `Builder` for sync values. Follow the [sync skill](../sync/SKILL.md)
178
+ for pagination and reconciliation.
179
+
180
+ Author properties in a keyed `schema` with explicit `resourceId` and `type`.
181
+ Both `datasources` keys and `schema` keys are names: each data-source key is
182
+ that source's name, and each schema key is the property's name used for typed
183
+ access, sync values, and serialized property arrays. Nested data-source configs
184
+ and property configs do not accept `name`. The top-level database `name` remains
185
+ supported; in the single-source shorthand it supplies both the database and
186
+ data-source display names. In a multi-source declaration, it names the database
187
+ while each source name comes from its key. The serialized intent retains the
188
+ server's existing arrays. Authoring-only `schema`, `datasources`, and
189
+ database-level `dataSourceResourceId` fields do not appear in the serialized
190
+ database intent. The provisioning JSON envelope is unchanged.
191
+ No IDs are generated.
152
192
  Each data source needs exactly one title property
153
193
  and unique property names and IDs. The current adapter supports title, text,
154
194
  number, select, multi-select, status, date, checkbox, URL, email, phone, and file
@@ -25,26 +25,21 @@ including declaration discovery and the supported schema types.
25
25
  This example declares an Issues database and syncs into its data source:
26
26
 
27
27
  ```ts
28
+ // src/syncs/issues.ts
28
29
  import { database, sync } from "@notionhq/apps";
29
30
  import { Builder } from "@notionhq/apps/builder";
30
31
 
31
- const issues = database({
32
- resourceId: "issues-db",
32
+ const issues = database("issues-db", {
33
+ dataSourceResourceId: "issues-source",
33
34
  name: "Issues",
34
- dataSources: [
35
- {
36
- resourceId: "issues-source",
37
- name: "Issues",
38
- properties: [
39
- { resourceId: "issue-name", name: "Name", type: "title" },
40
- { resourceId: "issue-id", name: "External ID", type: "text" },
41
- ],
42
- },
43
- ],
35
+ schema: {
36
+ Name: { resourceId: "issue-name", type: "title" },
37
+ "External ID": { resourceId: "issue-id", type: "text" },
38
+ },
44
39
  });
45
40
 
46
41
  export default sync({
47
- dataSource: issues.dataSources["issues-source"],
42
+ dataSource: issues.dataSource,
48
43
  primaryKey: "External ID",
49
44
  mode: "incremental",
50
45
  handler: async () => ({
@@ -60,11 +55,19 @@ export default sync({
60
55
  });
61
56
  ```
62
57
 
63
- Set `primaryKey` on the sync; it names a title or text property by its display
64
- name. Omit it from upsert properties: the SDK supplies it from `change.key`.
65
- Use Apps `Builder` values for sync results. Keep resource IDs stable and reuse
66
- the declared data source handle. Shared declarations can live in
67
- `src/lib/resources.ts`, imported by the sync.
58
+ Set `primaryKey` on the sync; it names a title or text property by its schema
59
+ key, which is also the serialized property name. Every upsert must include at least one
60
+ known non-primary-key schema property, while any other non-primary-key properties may be
61
+ omitted; unknown property keys are rejected by TypeScript. Omit the primary-key property
62
+ from upsert properties: the SDK supplies it from `change.key`. Use Apps `Builder` values for
63
+ sync results. Keep resource IDs stable and reuse the declared data source handle. A
64
+ sync-specific database may be declared inline or in a local helper. Shared or
65
+ otherwise app-wide declarations often fit in `src/notion.ts`.
66
+ Both `datasources` keys and `schema` keys are names: each data-source key is
67
+ the source's name, and each schema key is the property's name. Nested
68
+ data-source configs do not accept `name`. The top-level database `name` remains
69
+ supported; in the single-source shorthand it supplies both the database and
70
+ data-source display names.
68
71
 
69
72
  This skill covers only syncs backed by Notion as Code data sources. If the user
70
73
  asks to attach an existing database, explain that this recipe does not cover