@notionhq/apps 0.0.16 → 0.0.18

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 (77) hide show
  1. package/AGENTS.md +27 -0
  2. package/README.md +86 -36
  3. package/dist/connections.d.ts +39 -11
  4. package/dist/connections.d.ts.map +1 -1
  5. package/dist/connections.js +85 -21
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +2 -0
  9. package/dist/notion-as-code/database.d.ts +89 -48
  10. package/dist/notion-as-code/database.d.ts.map +1 -1
  11. package/dist/notion-as-code/database.js +132 -45
  12. package/dist/notion-as-code/database.test.d.ts +2 -0
  13. package/dist/notion-as-code/database.test.d.ts.map +1 -0
  14. package/dist/notion-as-code/handles.d.ts +2 -0
  15. package/dist/notion-as-code/handles.d.ts.map +1 -1
  16. package/dist/notion-as-code/handles.js +2 -0
  17. package/dist/notion-as-code/index.d.ts +5 -2
  18. package/dist/notion-as-code/index.d.ts.map +1 -1
  19. package/dist/notion-as-code/intents.d.ts +36 -3
  20. package/dist/notion-as-code/intents.d.ts.map +1 -1
  21. package/dist/notion-as-code/schema.d.ts +8 -4
  22. package/dist/notion-as-code/schema.d.ts.map +1 -1
  23. package/dist/notion-as-code/view.d.ts +12 -0
  24. package/dist/notion-as-code/view.d.ts.map +1 -0
  25. package/dist/notion-as-code/view.js +13 -0
  26. package/dist/notion-as-code/views-types.test.d.ts +2 -0
  27. package/dist/notion-as-code/views-types.test.d.ts.map +1 -0
  28. package/dist/notion-as-code/views.d.ts +488 -0
  29. package/dist/notion-as-code/views.d.ts.map +1 -0
  30. package/dist/notion-as-code/views.js +0 -0
  31. package/dist/oauth.d.ts +11 -0
  32. package/dist/oauth.d.ts.map +1 -0
  33. package/dist/oauth.js +26 -0
  34. package/dist/providers.generated.d.ts +26 -86
  35. package/dist/providers.generated.d.ts.map +1 -1
  36. package/dist/providers.generated.js +54 -66
  37. package/dist/sync.d.ts +33 -15
  38. package/dist/sync.d.ts.map +1 -1
  39. package/dist/sync.js +12 -0
  40. package/dist/triggers.generated.d.ts +3 -3
  41. package/dist/triggers.generated.d.ts.map +1 -1
  42. package/dist/workflow-state.d.ts +13 -0
  43. package/dist/workflow-state.d.ts.map +1 -0
  44. package/dist/workflow-state.js +107 -0
  45. package/dist/workflow.d.ts +56 -11
  46. package/dist/workflow.d.ts.map +1 -1
  47. package/dist/workflow.js +123 -10
  48. package/docs/BUILD.md +96 -0
  49. package/docs/CONNECTIONS.md +70 -22
  50. package/package.json +1 -1
  51. package/skills/connections/SKILL.md +7 -8
  52. package/skills/notion-as-code/SKILL.md +88 -50
  53. package/skills/sync/SKILL.md +21 -18
  54. package/skills/workflow/SKILL.md +52 -3
  55. package/src/cli/build.test.ts +124 -64
  56. package/src/connections.test.ts +205 -41
  57. package/src/connections.ts +144 -38
  58. package/src/index.ts +2 -0
  59. package/src/notion-as-code/database.test.ts +661 -0
  60. package/src/notion-as-code/database.ts +346 -129
  61. package/src/notion-as-code/handles.ts +2 -0
  62. package/src/notion-as-code/index.ts +71 -1
  63. package/src/notion-as-code/intents.ts +41 -4
  64. package/src/notion-as-code/schema.ts +11 -3
  65. package/src/notion-as-code/view.ts +23 -0
  66. package/src/notion-as-code/views-types.test.ts +59 -0
  67. package/src/notion-as-code/views.ts +573 -0
  68. package/src/oauth.ts +40 -0
  69. package/src/providers.generated.ts +68 -163
  70. package/src/sync.test.ts +295 -0
  71. package/src/sync.ts +85 -21
  72. package/src/triggers.generated.ts +4 -4
  73. package/src/workflow-connections-types.test.ts +16 -10
  74. package/src/workflow-state.ts +152 -0
  75. package/src/workflow-types.test.ts +88 -16
  76. package/src/workflow.test.ts +374 -18
  77. package/src/workflow.ts +211 -23
@@ -17,12 +17,12 @@ 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
26
 
27
27
  ## Where declarations are picked up
28
28
 
@@ -33,24 +33,27 @@ same rule. Their resource type does not determine their file location.
33
33
 
34
34
  ```text
35
35
  src/
36
- lib/
37
- resources.ts Shared page/database declarations
36
+ notion.ts App-wide resource declarations
38
37
  syncs/
39
- issues.ts Default-exported sync; imports ../lib/resources
40
- lib/
41
- issueSchema.ts Sync-specific helper; must be imported
38
+ issues.ts Default-exported sync; imports ../notion.js
42
39
  workflows/
43
- notify.ts Default-exported workflow
40
+ notify.ts May declare a small related resource inline
41
+ myWorkflow.ts Imports ./myWorkflow/lib/notion.js
42
+ myWorkflow/
43
+ lib/
44
+ notion.ts Resources owned by myWorkflow
44
45
  ```
45
46
 
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.
47
+ Choose the location based on ownership, not resource type. Export an app-wide
48
+ database handle from `src/notion.ts` and import it in `src/syncs/issues.ts` when
49
+ the sync uses it. A declaration-only module can be loaded with a side-effect
50
+ import. Keep these imports at module scope so build evaluation runs the
51
+ declarations.
51
52
 
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.
53
+ Resource helper modules, including `src/notion.ts`, are not automatically scanned.
54
+ There is no automatically scanned `src/pages/` or `src/databases/` directory
55
+ either. Helpers under capability-specific `lib/` directories are not discovered
56
+ on their own.
54
57
  Every direct file in a capability directory must still default-export that
55
58
  capability, so do not put a resource-only file there. Imports reached only
56
59
  through `src/customBlocks/` or browser code do not enter the Notion as Code recording
@@ -63,15 +66,43 @@ Read the installed Notion as Code types before choosing fields. The Apps
63
66
  root exports support:
64
67
 
65
68
  - `teamspace({ resourceId, name, accessLevel })`, with `addPage` and
66
- `addDatabase` on the returned handle.
69
+ `teamspace.addDatabase(id, args)` on the returned handle.
67
70
  - `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`.
71
+ `addPage` and `page.addDatabase(id, args)` for children.
72
+ - `database(resourceId, { dataSourceResourceId, name, schema, parent?, views? })`,
73
+ returning a single source as `handle.dataSource`, or
74
+ `database(resourceId, { datasources: { Key: { resourceId, schema } }, views?, parent? })`,
75
+ returning sources as `handle.datasources.Key`. A source's `addPage` declares a row;
76
+ the database handle also exposes `addView`.
72
77
  - `customAgent({ resourceId, name, instructions?, sharedResources? })`.
73
78
  Shared resources are declared resource IDs; inspect the installed types
74
79
  before specifying models or triggers.
80
+ - `view({ databaseResourceId, resourceId, type, dataSourceResourceId, ... })`,
81
+ returning a handle with `resourceId`. This is also available as `notion.view`.
82
+ Use property resource IDs in view filters, sorts, and layout options; calendar
83
+ views require `calendarBy`, and timeline views require `timelineBy`.
84
+
85
+ A database declaration must include at least one data source or at least one
86
+ view. A linked-only database may omit `datasources`, or use `datasources: {}`,
87
+ only when `views` is nonempty. `{}`, `{ datasources: {} }`, and
88
+ `{ datasources: {}, views: [] }` are invalid; widened or dynamic maps and arrays
89
+ are checked at runtime as well as by the type-level forms.
90
+
91
+ ```ts
92
+ const linkedIssues = database("project-issues-linked", {
93
+ views: [
94
+ {
95
+ resourceId: "project-issues-table",
96
+ type: "table",
97
+ dataSourceResourceId: "issues-source",
98
+ properties: [{ property: "issue-title", visible: true }],
99
+ },
100
+ ],
101
+ });
102
+ ```
103
+
104
+ The table view above is typed by its `type: "table"` schema and links to the
105
+ explicit `issues-source` resource ID.
75
106
 
76
107
  Keep resource IDs stable across builds. They identify declarations, not live
77
108
  Notion UUIDs. Avoid the reserved `__notion_apps_` prefix. Explicit parents
@@ -79,48 +110,43 @@ use `{ type: "resourceId", resourceId: parent.resourceId }`; child helpers set
79
110
  this reference for you. Pages and databases without a parent default to private
80
111
  top-level resources in the Apps workspace.
81
112
 
82
- Several fields, including views, covers, page layouts, and custom-agent
83
- triggers, still have incomplete types. A field typed as `unknown` is not proof
113
+ Several fields, including database covers and custom-agent triggers,
114
+ still have incomplete types. A field typed as `unknown` is not proof
84
115
  that any payload is supported. Check implementation and examples before using
85
- it; do not assume parity with other Notion as Code packages.
116
+ it; do not assume parity with other Notion as Code packages. Database views are
117
+ typed with `ViewSchema`; standalone declarations accept `ViewArgs`, which adds
118
+ the owning `databaseResourceId`.
86
119
 
87
120
  The Apps SDK exposes a subset of Notion as Code. Data sources are nested inside
88
- `database({ dataSources: [...] })`, not declared by a separate
121
+ `database(resourceId, { datasources: { ... } })`, not declared by a separate
89
122
  `dataSource` function. It has no `space` workspace declaration:
90
123
  Apps deployment rejects workspace creation or changes and supplies the App's
91
124
  workspace binding itself. Value helpers and types stay on their existing
92
125
  subpaths. For example, import `{ notion }` from `@notionhq/apps/notion-as-code`
93
126
  for `notion.text(...)` or `notion.file(resourceId)`. The latter creates a file
94
127
  reference, not an upload or file declaration. These helpers are not root exports.
95
- CLI acceptance of
96
- an intent envelope alone does not establish server support for its contents.
128
+ CLI acceptance of a serialized intent envelope alone does not establish server
129
+ support for its contents.
97
130
 
98
131
  ## Use a declared data source in a sync
99
132
 
100
- This example can live directly in `src/syncs/issues.ts`. Move the resource
101
- declaration into an imported helper when sharing it with other capabilities.
133
+ This sync-specific example declares the resource inline in `src/syncs/issues.ts`:
102
134
 
103
135
  ```ts
104
136
  import { database, sync } from "@notionhq/apps";
105
137
  import { Builder } from "@notionhq/apps/builder";
106
138
 
107
- const issues = database({
108
- resourceId: "issues-db",
139
+ const issues = database("issues-db", {
140
+ dataSourceResourceId: "issues-source",
109
141
  name: "Issues",
110
- dataSources: [
111
- {
112
- resourceId: "issues-source",
113
- name: "Issues",
114
- properties: [
115
- { resourceId: "issue-name", name: "Name", type: "title" },
116
- { resourceId: "issue-id", name: "External ID", type: "text" },
117
- ],
118
- },
119
- ],
142
+ schema: {
143
+ Name: { resourceId: "issue-name", type: "title" },
144
+ "External ID": { resourceId: "issue-id", type: "text" },
145
+ },
120
146
  });
121
147
 
122
148
  export default sync({
123
- dataSource: issues.dataSources["issues-source"],
149
+ dataSource: issues.dataSource,
124
150
  primaryKey: "External ID",
125
151
  mode: "incremental",
126
152
  handler: async () => ({
@@ -138,11 +164,23 @@ export default sync({
138
164
 
139
165
  Pass a data source handle, not the whole database handle or a UUID. The SDK
140
166
  derives the sync schema from that handle. `primaryKey` names a title or text
141
- property by its display name, not its resource ID. Omit that property from
142
- upsert values; the SDK fills it from `key`. Use Apps `Builder` for sync values.
143
- Follow the [sync skill](../sync/SKILL.md) for pagination and reconciliation.
144
-
145
- Notion as Code properties are an array with resource IDs and names.
167
+ property by its schema key, which is also its serialized property name, not its
168
+ resource ID. Omit that property from upsert values; the SDK fills it from `key`.
169
+ Use Apps `Builder` for sync values. Follow the [sync skill](../sync/SKILL.md)
170
+ for pagination and reconciliation.
171
+
172
+ Author properties in a keyed `schema` with explicit `resourceId` and `type`.
173
+ Both `datasources` keys and `schema` keys are names: each data-source key is
174
+ that source's name, and each schema key is the property's name used for typed
175
+ access, sync values, and serialized property arrays. Nested data-source configs
176
+ and property configs do not accept `name`. The top-level database `name` remains
177
+ supported; in the single-source shorthand it supplies both the database and
178
+ data-source display names. In a multi-source declaration, it names the database
179
+ while each source name comes from its key. The serialized intent retains the
180
+ server's existing arrays. Authoring-only `schema`, `datasources`, and
181
+ database-level `dataSourceResourceId` fields do not appear in the serialized
182
+ database intent. The provisioning JSON envelope is unchanged.
183
+ No IDs are generated.
146
184
  Each data source needs exactly one title property
147
185
  and unique property names and IDs. The current adapter supports title, text,
148
186
  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
@@ -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
@@ -67,8 +114,9 @@ Use [Notion as Code](../notion-as-code/SKILL.md) for pages and databases that
67
114
  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 in
71
- an imported helper. For example, declare a guide page in `src/lib/resources.ts`:
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";
@@ -82,10 +130,11 @@ export const guide = page({
82
130
  Import the module from `src/workflows/sayHello.ts` so the build records it:
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 "./sayHello/lib/notion.js";
137
+
89
138
  export default workflow({
90
139
  name: "Say Hello",
91
140
  description: "Says hello on a recurring schedule.",
@@ -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 database = notion.database({
19
- resourceId: "issues",
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
- properties: [
22
- { resourceId: "title", name: "Name", type: "title" },
23
- { resourceId: "count", name: "Count", type: "number" },
24
- ],
25
+ schema,
25
26
  });
26
- expectTypeOf(database).toExtend<
27
- SingleSourceDatabaseHandle<typeof database.dataSource.schema>
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
- dataSources: [
40
- {
39
+ datasources: {
40
+ Issues: {
41
41
  resourceId: "issues-source",
42
- name: "Issues",
43
- properties: database.dataSource.schema,
42
+ schema,
44
43
  },
45
- ],
44
+ },
46
45
  });
47
- explicit.dataSources["issues-source"].addPage({ resourceId: "issue" });
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 inferred source types through child helpers", () => {
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
- resourceId: `${parent.resourceId}-db`,
69
+ const database = parent.addDatabase(`${parent.resourceId}-db`, {
70
+ dataSourceResourceId: `${parent.resourceId}-source`,
66
71
  name: "Child",
67
- properties: [
68
- { resourceId: `${parent.resourceId}-title`, name: "Title", type: "title" },
69
- ],
72
+ schema,
70
73
  });
71
- expectTypeOf(database.dataSource).toExtend<
72
- DataSourceHandle<typeof database.dataSource.schema>
73
- >();
74
- expectTypeOf(database.dataSource.schema[0].name).toEqualTypeOf<"Title">();
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("rejects mixed forms before recording an intent", () => {
89
+ it("keeps keyed child inference and rejects empty child databases", () => {
86
90
  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
- });
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
- expect(declareMixedDatabase).toThrow();
97
- expect(finishMetadataRecording(recorder).intents).toEqual([]);
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
 
@@ -163,41 +208,49 @@ describe("buildApp", () => {
163
208
  it("builds a sync manifest with a Notion-as-Code meetings database", async () => {
164
209
  const { manifest } = await buildApp(example("sync"));
165
210
 
166
- expect(manifest).toEqual({
167
- $schema: "notion:apps-manifest:v1",
168
- sdkVersion: SDK_VERSION,
169
- databases: [
170
- {
211
+ expect(manifest).toEqual(
212
+ expect.objectContaining({
213
+ $schema: "notion:apps-manifest:v1",
214
+ sdkVersion: SDK_VERSION,
215
+ xldbs: [],
216
+ pacers: [],
217
+ }),
218
+ );
219
+ expect(manifest.databases).toHaveLength(2);
220
+ expect(manifest.databases).toEqual(
221
+ expect.arrayContaining([
222
+ expect.objectContaining({
171
223
  key: "issues",
172
- config: {
224
+ config: expect.objectContaining({
173
225
  initialTitle: "GitHub Issues",
174
- schema: {
175
- properties: {
226
+ schema: expect.objectContaining({
227
+ properties: expect.objectContaining({
176
228
  Name: { type: "title" },
177
229
  "GitHub ID": { type: "text" },
178
230
  Closed: { type: "checkbox" },
179
- },
180
- },
181
- },
182
- },
183
- {
231
+ }),
232
+ }),
233
+ }),
234
+ }),
235
+ expect.objectContaining({
184
236
  key: "homebase-meetings-db-source",
185
- config: {
237
+ config: expect.objectContaining({
186
238
  initialTitle: "Meetings",
187
- schema: {
188
- properties: {
239
+ schema: expect.objectContaining({
240
+ properties: expect.objectContaining({
189
241
  Name: { type: "title" },
190
242
  "Meeting ID": { type: "text" },
191
243
  "Meeting date": { type: "date" },
192
244
  "Prep complete": { type: "checkbox" },
193
- },
194
- },
195
- },
196
- },
197
- ],
198
- xldbs: [],
199
- pacers: [],
200
- capabilities: [
245
+ }),
246
+ }),
247
+ }),
248
+ }),
249
+ ]),
250
+ );
251
+ expect(manifest.capabilities).toHaveLength(2);
252
+ expect(manifest.capabilities).toEqual(
253
+ expect.arrayContaining([
201
254
  {
202
255
  type: "sync",
203
256
  key: "issues",
@@ -218,8 +271,8 @@ describe("buildApp", () => {
218
271
  schedule: { type: "interval", intervalMs: 3_600_000 },
219
272
  },
220
273
  },
221
- ],
222
- });
274
+ ]),
275
+ );
223
276
  });
224
277
 
225
278
  it("emits a separate Notion-as-Code provisioning artifact", async () => {
@@ -285,6 +338,13 @@ describe("buildApp", () => {
285
338
  resourceId: "__notion_apps_workspace__",
286
339
  },
287
340
  dataSources: [],
341
+ views: [
342
+ {
343
+ resourceId: "github-app-private-view",
344
+ type: "table",
345
+ dataSourceResourceId: "issues-source",
346
+ },
347
+ ],
288
348
  },
289
349
  {
290
350
  type: "database",