@notionhq/apps 0.0.4 → 0.0.7

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 (71) hide show
  1. package/README.md +53 -2
  2. package/dist/builder.d.ts +29 -0
  3. package/dist/builder.d.ts.map +1 -0
  4. package/dist/builder.js +86 -0
  5. package/dist/builder.test.d.ts +2 -0
  6. package/dist/builder.test.d.ts.map +1 -0
  7. package/dist/cli/build.js +1 -1
  8. package/dist/cli/discover.d.ts +2 -2
  9. package/dist/cli/discover.d.ts.map +1 -1
  10. package/dist/cli/discover.js +2 -1
  11. package/dist/cli/emit-manifest.d.ts +2 -2
  12. package/dist/cli/emit-manifest.d.ts.map +1 -1
  13. package/dist/cli/emit-manifest.js +177 -27
  14. package/dist/context.js +2 -2
  15. package/dist/database.d.ts +13 -0
  16. package/dist/database.d.ts.map +1 -0
  17. package/dist/database.js +9 -0
  18. package/dist/error.d.ts +7 -0
  19. package/dist/error.d.ts.map +1 -1
  20. package/dist/error.js +10 -1
  21. package/dist/manifest.d.ts +30 -5
  22. package/dist/manifest.d.ts.map +1 -1
  23. package/dist/manifest.js +4 -0
  24. package/dist/pacer.d.ts +15 -0
  25. package/dist/pacer.d.ts.map +1 -0
  26. package/dist/pacer.js +63 -0
  27. package/dist/runtime-input.d.ts +2 -0
  28. package/dist/runtime-input.d.ts.map +1 -0
  29. package/dist/runtime-input.js +20 -0
  30. package/dist/runtime-input.test.d.ts +2 -0
  31. package/dist/runtime-input.test.d.ts.map +1 -0
  32. package/dist/schedule.d.ts +3 -0
  33. package/dist/schedule.d.ts.map +1 -0
  34. package/dist/schedule.js +23 -0
  35. package/dist/schema.d.ts +111 -0
  36. package/dist/schema.d.ts.map +1 -0
  37. package/dist/schema.js +39 -0
  38. package/dist/sync.d.ts +72 -0
  39. package/dist/sync.d.ts.map +1 -0
  40. package/dist/sync.js +75 -0
  41. package/dist/sync.test.d.ts +2 -0
  42. package/dist/sync.test.d.ts.map +1 -0
  43. package/dist/types.d.ts +85 -0
  44. package/dist/types.d.ts.map +1 -0
  45. package/dist/types.js +0 -0
  46. package/dist/workflow.d.ts.map +1 -1
  47. package/dist/workflow.js +2 -0
  48. package/docs/BUILD.md +1 -0
  49. package/package.json +33 -2
  50. package/src/builder.test.ts +81 -0
  51. package/src/builder.ts +99 -0
  52. package/src/cli/build.test.ts +109 -0
  53. package/src/cli/build.ts +1 -1
  54. package/src/cli/codegen.test.ts +15 -0
  55. package/src/cli/discover.test.ts +13 -2
  56. package/src/cli/discover.ts +3 -2
  57. package/src/cli/emit-manifest.ts +229 -53
  58. package/src/context.ts +6 -6
  59. package/src/database.ts +25 -0
  60. package/src/error.ts +11 -0
  61. package/src/manifest.ts +33 -5
  62. package/src/pacer.ts +82 -0
  63. package/src/runtime-input.test.ts +65 -0
  64. package/src/runtime-input.ts +30 -0
  65. package/src/schedule.ts +25 -0
  66. package/src/schema.ts +73 -0
  67. package/src/sync.test.ts +359 -0
  68. package/src/sync.ts +190 -0
  69. package/src/types.ts +109 -0
  70. package/src/workflow.test.ts +22 -0
  71. package/src/workflow.ts +2 -0
package/README.md CHANGED
@@ -7,13 +7,64 @@
7
7
  3. Run `mise run setup`
8
8
  4. Run `mise run check && mise run test`
9
9
 
10
- The SDK requires Node 26 or newer and supports workflow capabilities only:
10
+ The SDK requires Node 26 or newer. It supports workflows and database syncs.
11
11
 
12
12
  ```ts
13
13
  import { triggers } from "@notionhq/apps/triggers";
14
14
  import { createWorkflow } from "@notionhq/apps/workflow";
15
15
  ```
16
16
 
17
+ ## Notion API access
18
+
19
+ Use `context.notion` for Notion API calls. Deployed apps receive Notion API
20
+ credentials automatically, so do not configure or push `NOTION_API_TOKEN`.
21
+ Local execution needs the token in `.env` before making a Notion API request.
22
+
23
+ ## Database syncs
24
+
25
+ Apps declare a database schema, and users attach a real Notion database to it. Apps SDK
26
+ databases are always attached; there is no managed-database option.
27
+
28
+ ```ts
29
+ import { Builder } from "@notionhq/apps/builder";
30
+ import { createDatabase } from "@notionhq/apps/database";
31
+ import { Schema } from "@notionhq/apps/schema";
32
+ import { createSync } from "@notionhq/apps/sync";
33
+
34
+ const issues = createDatabase("issues", {
35
+ schema: {
36
+ Name: Schema.title(),
37
+ "GitHub ID": Schema.richText(),
38
+ },
39
+ });
40
+
41
+ export default createSync({
42
+ database: issues,
43
+ primaryKey: "GitHub ID",
44
+ handler: async () => ({
45
+ changes: [
46
+ {
47
+ type: "upsert",
48
+ key: "123",
49
+ properties: { Name: Builder.title("Fix the bug") },
50
+ },
51
+ ],
52
+ }),
53
+ });
54
+ ```
55
+
56
+ Put each default-exported sync directly in `src/syncs/`. A primary key must be a title
57
+ or rich-text property. Do not include that property in an upsert: the SDK fills it from
58
+ the change's `key`.
59
+
60
+ ### Moving from the Workers alpha SDK
61
+
62
+ - Remove `type` and `primaryKey` from `createDatabase`.
63
+ - Move the property map from `schema.properties` directly into `schema`.
64
+ - Add `primaryKey` to each `createSync`.
65
+ - Remove the primary-key property from each upsert's `properties` object.
66
+ - Change imports from `@notionhq/workers/alpha/*` to `@notionhq/apps/*`.
67
+
17
68
  ## Publishing New Versions
18
69
 
19
70
  Publishing is initiated only from the GitHub Actions UI:
@@ -22,5 +73,5 @@ Publishing is initiated only from the GitHub Actions UI:
22
73
  2. Select **Run workflow** on the `main` branch.
23
74
 
24
75
  The workflow always increments the patch version. It builds, checks, and tests the
25
- SDK before committing the new version, creating an `apps-v*` GitHub release,
76
+ SDK before committing the new version, creating a `v*` GitHub release,
26
77
  and publishing `@notionhq/apps` to npm.
@@ -0,0 +1,29 @@
1
+ import type { Cover, Icon, NoticonColor, PeopleValue, PlaceValue, RelationReference, TextValue } from "./types.js";
2
+ /** Build property values returned by a sync handler. */
3
+ export declare const Builder: {
4
+ richText: (content: string) => TextValue;
5
+ text: (content: string) => TextValue;
6
+ title: (content: string) => TextValue;
7
+ url: (url: string) => TextValue;
8
+ email: (email: string) => TextValue;
9
+ phoneNumber: (phone: string) => TextValue;
10
+ checkbox: (checked: boolean) => TextValue;
11
+ file: (url: string, name?: string) => TextValue;
12
+ number: (value: number) => TextValue;
13
+ date(value: string): TextValue;
14
+ dateTime(value: string, timeZone?: string): TextValue;
15
+ dateRange(startDate: string, endDate: string): TextValue;
16
+ dateTimeRange(startValue: string, endValue: string, timeZone?: string): TextValue;
17
+ link: (text: string, url: string) => TextValue;
18
+ select: (value: string) => TextValue;
19
+ multiSelect: (...values: string[]) => TextValue;
20
+ status: (value: string) => TextValue;
21
+ people: (...emails: string[]) => PeopleValue;
22
+ place(value: PlaceValue): PlaceValue;
23
+ relation: (primaryKey: string) => RelationReference;
24
+ emojiIcon: (value: string) => Icon;
25
+ notionIcon: (icon: string, color?: NoticonColor) => Icon;
26
+ imageIcon: (url: string) => Icon;
27
+ imageCover(url: string, position?: number): Cover;
28
+ };
29
+ //# sourceMappingURL=builder.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"builder.d.ts","sourceRoot":"","sources":["../src/builder.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACX,KAAK,EAEL,IAAI,EACJ,YAAY,EACZ,WAAW,EACX,UAAU,EACV,iBAAiB,EACjB,SAAS,EACT,MAAM,YAAY,CAAC;AAmBpB,wDAAwD;AACxD,eAAO,MAAM,OAAO;wBACC,MAAM,KAAG,SAAS;oBACtB,MAAM,KAAG,SAAS;qBACjB,MAAM,KAAG,SAAS;eACxB,MAAM,KAAG,SAAS;mBACd,MAAM,KAAG,SAAS;yBACZ,MAAM,KAAG,SAAS;wBACnB,OAAO,KAAG,SAAS;gBAC3B,MAAM,SAAS,MAAM,KAAG,SAAS;oBAC7B,MAAM,KAAG,SAAS;gBACtB,MAAM,GAAG,SAAS;oBAId,MAAM,aAAa,MAAM,GAAG,SAAS;yBAShC,MAAM,WAAW,MAAM,GAAG,SAAS;8BAK9B,MAAM,YAAY,MAAM,aAAa,MAAM,GAAG,SAAS;iBAYpE,MAAM,OAAO,MAAM,KAAG,SAAS;oBAC5B,MAAM,KAAG,SAAS;6BACT,MAAM,EAAE,KAAG,SAAS;oBAE7B,MAAM,KAAG,SAAS;wBACd,MAAM,EAAE,KAAG,WAAW;iBAC7B,UAAU,GAAG,UAAU;2BAMb,MAAM,KAAG,iBAAiB;uBAI9B,MAAM,KAAG,IAAI;uBACb,MAAM,UAAS,YAAY,KAAY,IAAI;qBAK7C,MAAM,KAAG,IAAI;oBACd,MAAM,sBAAmB,KAAK;CAM9C,CAAC"}
@@ -0,0 +1,86 @@
1
+ function validateDateString(value) {
2
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || Number.isNaN(new Date(value).getTime())) {
3
+ throw new Error(`Invalid date: ${value}. Expected YYYY-MM-DD.`);
4
+ }
5
+ }
6
+ function dateToken(value) {
7
+ return [["\u2023", [["d", value]]]];
8
+ }
9
+ function parseISODateTime(value) {
10
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value) || Number.isNaN(new Date(value).getTime())) {
11
+ throw new Error(`Invalid ISO 8601 datetime: ${value}`);
12
+ }
13
+ return { date: value.slice(0, 10), time: value.slice(11, 16) };
14
+ }
15
+ const Builder = {
16
+ richText: (content) => [[content]],
17
+ text: (content) => [[content]],
18
+ title: (content) => [[content]],
19
+ url: (url) => [[url]],
20
+ email: (email) => [[email]],
21
+ phoneNumber: (phone) => [[phone]],
22
+ checkbox: (checked) => checked ? [["Yes"]] : [["No"]],
23
+ file: (url, name) => [[name ?? url, [["a", url]]]],
24
+ number: (value) => Number.isNaN(value) ? [] : [[String(value)]],
25
+ date(value) {
26
+ validateDateString(value);
27
+ return dateToken({ type: "date", start_date: value });
28
+ },
29
+ dateTime(value, timeZone) {
30
+ const parsed = parseISODateTime(value);
31
+ return dateToken({
32
+ type: "datetime",
33
+ start_date: parsed.date,
34
+ start_time: parsed.time,
35
+ ...timeZone === void 0 ? {} : { time_zone: timeZone }
36
+ });
37
+ },
38
+ dateRange(startDate, endDate) {
39
+ validateDateString(startDate);
40
+ validateDateString(endDate);
41
+ return dateToken({ type: "daterange", start_date: startDate, end_date: endDate });
42
+ },
43
+ dateTimeRange(startValue, endValue, timeZone) {
44
+ const start = parseISODateTime(startValue);
45
+ const end = parseISODateTime(endValue);
46
+ return dateToken({
47
+ type: "datetimerange",
48
+ start_date: start.date,
49
+ start_time: start.time,
50
+ end_date: end.date,
51
+ end_time: end.time,
52
+ ...timeZone === void 0 ? {} : { time_zone: timeZone }
53
+ });
54
+ },
55
+ link: (text, url) => [[text, [["a", url]]]],
56
+ select: (value) => [[value]],
57
+ multiSelect: (...values) => values.length === 0 ? [] : [[values.join(",")]],
58
+ status: (value) => [[value]],
59
+ people: (...emails) => emails.map((email) => ({ email })),
60
+ place(value) {
61
+ if (!Number.isFinite(value.lat) || !Number.isFinite(value.lon)) {
62
+ throw new Error("Place value must have numeric lat and lon coordinates");
63
+ }
64
+ return value;
65
+ },
66
+ relation: (primaryKey) => ({
67
+ type: "primaryKey",
68
+ value: primaryKey
69
+ }),
70
+ emojiIcon: (value) => ({ type: "emoji", value }),
71
+ notionIcon: (icon, color = "gray") => ({
72
+ type: "notion",
73
+ icon,
74
+ color
75
+ }),
76
+ imageIcon: (url) => ({ type: "image", url }),
77
+ imageCover(url, position = 0.5) {
78
+ if (!Number.isFinite(position) || position < 0 || position > 1) {
79
+ throw new Error("Cover position must be between 0 and 1");
80
+ }
81
+ return { type: "image", url, position };
82
+ }
83
+ };
84
+ export {
85
+ Builder
86
+ };
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=builder.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"builder.test.d.ts","sourceRoot":"","sources":["../src/builder.test.ts"],"names":[],"mappings":""}
package/dist/cli/build.js CHANGED
@@ -8,7 +8,7 @@ async function buildApp(projectRoot) {
8
8
  const capabilities = await discoverCapabilities(projectRoot);
9
9
  if (capabilities.length === 0) {
10
10
  throw new Error(
11
- `No capabilities found in ${projectRoot}. Add a workflow file under src/workflows/<name>.ts that default-exports createWorkflow(...).`
11
+ `No capabilities found in ${projectRoot}. Add a workflow under src/workflows/ or a sync under src/syncs/.`
12
12
  );
13
13
  }
14
14
  const entryPath = path.join(projectRoot, ".notion", "entry.ts");
@@ -2,7 +2,7 @@ import type { CapabilityType } from "../manifest.js";
2
2
  /**
3
3
  * A capability type the build tool knows how to discover: which directory
4
4
  * under `src/` its files live in, and the `_tag` its default exports must
5
- * carry. This SDK intentionally registers workflows only.
5
+ * carry. This SDK registers workflows and syncs.
6
6
  */
7
7
  export type CapabilityTypeDefinition = {
8
8
  type: CapabilityType;
@@ -26,7 +26,7 @@ export type DiscoveredCapability = {
26
26
  * `src/<directory>/` for each registered capability type. Subdirectories are
27
27
  * not searched — they hold plain modules, not capabilities.
28
28
  *
29
- * Only workflow files directly under `src/workflows/` are discovered.
29
+ * Workflow and sync files are discovered from their matching directories.
30
30
  */
31
31
  export declare function discoverCapabilities(projectRoot: string): Promise<DiscoveredCapability[]>;
32
32
  //# sourceMappingURL=discover.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"discover.d.ts","sourceRoot":"","sources":["../../src/cli/discover.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAErD;;;;GAIG;AACH,MAAM,MAAM,wBAAwB,GAAG;IACtC,IAAI,EAAE,cAAc,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,GAAG,EAAE,MAAM,CAAC;CACZ,CAAC;AAEF,eAAO,MAAM,gBAAgB,EAAE,SAAS,wBAAwB,EAE/D,CAAC;AAEF;;;GAGG;AACH,MAAM,MAAM,oBAAoB,GAAG;IAClC,IAAI,EAAE,cAAc,CAAC;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,6DAA6D;IAC7D,UAAU,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF;;;;;;GAMG;AACH,wBAAsB,oBAAoB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,oBAAoB,EAAE,CAAC,CAiC/F"}
1
+ {"version":3,"file":"discover.d.ts","sourceRoot":"","sources":["../../src/cli/discover.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAErD;;;;GAIG;AACH,MAAM,MAAM,wBAAwB,GAAG;IACtC,IAAI,EAAE,cAAc,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,GAAG,EAAE,MAAM,CAAC;CACZ,CAAC;AAEF,eAAO,MAAM,gBAAgB,EAAE,SAAS,wBAAwB,EAG/D,CAAC;AAEF;;;GAGG;AACH,MAAM,MAAM,oBAAoB,GAAG;IAClC,IAAI,EAAE,cAAc,CAAC;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,6DAA6D;IAC7D,UAAU,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF;;;;;;GAMG;AACH,wBAAsB,oBAAoB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,oBAAoB,EAAE,CAAC,CAiC/F"}
@@ -1,7 +1,8 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  const CAPABILITY_TYPES = [
4
- { type: "workflow", directory: "workflows", tag: "workflow" }
4
+ { type: "workflow", directory: "workflows", tag: "workflow" },
5
+ { type: "sync", directory: "syncs", tag: "sync" }
5
6
  ];
6
7
  async function discoverCapabilities(projectRoot) {
7
8
  const discovered = [];
@@ -1,10 +1,10 @@
1
1
  import type { AppManifest } from "../manifest.js";
2
2
  import type { DiscoveredCapability } from "./discover.js";
3
- /** Extract the workflow-only app manifest from a built bundle. */
3
+ /** Extract the app manifest from a built bundle. */
4
4
  export declare function extractManifest(bundlePath: string, capabilities: readonly DiscoveredCapability[]): Promise<AppManifest>;
5
5
  /**
6
6
  * Throw if `value` contains anything `JSON.stringify` would silently drop or
7
- * rewrite, so invalid workflow configuration fails during the build.
7
+ * rewrite, so invalid manifest configuration fails during the build.
8
8
  */
9
9
  export declare function assertJsonSafe(value: unknown, path: string): void;
10
10
  //# sourceMappingURL=emit-manifest.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"emit-manifest.d.ts","sourceRoot":"","sources":["../../src/cli/emit-manifest.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AAgD1D,kEAAkE;AAClE,wBAAsB,eAAe,CACpC,UAAU,EAAE,MAAM,EAClB,YAAY,EAAE,SAAS,oBAAoB,EAAE,GAC3C,OAAO,CAAC,WAAW,CAAC,CAmCtB;AAgDD;;;GAGG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CA4CjE"}
1
+ {"version":3,"file":"emit-manifest.d.ts","sourceRoot":"","sources":["../../src/cli/emit-manifest.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,WAAW,EAA6C,MAAM,gBAAgB,CAAC;AAE7F,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AA0M1D,oDAAoD;AACpD,wBAAsB,eAAe,CACpC,UAAU,EAAE,MAAM,EAClB,YAAY,EAAE,SAAS,oBAAoB,EAAE,GAC3C,OAAO,CAAC,WAAW,CAAC,CA6EtB;AAkCD;;;GAGG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAoCjE"}
@@ -1,6 +1,8 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { pathToFileURL } from "node:url";
3
3
  import { Ajv } from "ajv";
4
+ import { APPS_MANIFEST_SCHEMA } from "../manifest.js";
5
+ import { clearRegisteredPacers, registeredPacers } from "../pacer.js";
4
6
  const SDK_VERSION = (() => {
5
7
  const require2 = createRequire(import.meta.url);
6
8
  const packageJson = require2("../../package.json");
@@ -12,16 +14,55 @@ const SDK_VERSION = (() => {
12
14
  const MANIFEST_SCHEMA = {
13
15
  type: "object",
14
16
  properties: {
17
+ $schema: { type: "string", enum: [APPS_MANIFEST_SCHEMA] },
15
18
  sdkVersion: { type: "string" },
16
- databases: { type: "array", maxItems: 0 },
19
+ databases: {
20
+ type: "array",
21
+ items: {
22
+ type: "object",
23
+ properties: {
24
+ key: { type: "string" },
25
+ config: {
26
+ type: "object",
27
+ properties: {
28
+ initialTitle: { type: "string" },
29
+ schema: { type: "object" }
30
+ },
31
+ required: ["schema"],
32
+ additionalProperties: false
33
+ }
34
+ },
35
+ required: ["key", "config"],
36
+ additionalProperties: false
37
+ }
38
+ },
17
39
  xldbs: { type: "array", maxItems: 0 },
18
- pacers: { type: "array", maxItems: 0 },
40
+ pacers: {
41
+ type: "array",
42
+ items: {
43
+ type: "object",
44
+ properties: {
45
+ key: { type: "string" },
46
+ config: {
47
+ type: "object",
48
+ properties: {
49
+ allowedRequests: { type: "number", exclusiveMinimum: 0 },
50
+ intervalMs: { type: "number", exclusiveMinimum: 0 }
51
+ },
52
+ required: ["allowedRequests", "intervalMs"],
53
+ additionalProperties: false
54
+ }
55
+ },
56
+ required: ["key", "config"],
57
+ additionalProperties: false
58
+ }
59
+ },
19
60
  capabilities: {
20
61
  type: "array",
21
62
  items: {
22
63
  type: "object",
23
64
  properties: {
24
- type: { type: "string", const: "workflow" },
65
+ type: { type: "string", enum: ["workflow", "sync"] },
25
66
  key: { type: "string" },
26
67
  config: {}
27
68
  },
@@ -30,29 +71,147 @@ const MANIFEST_SCHEMA = {
30
71
  }
31
72
  }
32
73
  },
33
- required: ["sdkVersion", "databases", "xldbs", "pacers", "capabilities"],
74
+ required: ["$schema", "sdkVersion", "databases", "xldbs", "pacers", "capabilities"],
34
75
  additionalProperties: false
35
76
  };
77
+ function asTagged(value) {
78
+ if (typeof value === "object" && value !== null && "_tag" in value && typeof value._tag === "string" && "config" in value && typeof value.config === "object" && value.config !== null) {
79
+ return value;
80
+ }
81
+ return void 0;
82
+ }
83
+ function asDatabase(value) {
84
+ const tagged = asTagged(value);
85
+ if (tagged?._tag === "database" && "key" in tagged && typeof tagged.key === "string") {
86
+ return tagged;
87
+ }
88
+ return void 0;
89
+ }
90
+ function schemaProperties(database) {
91
+ const schema = database.config.schema;
92
+ if (typeof schema !== "object" || schema === null || Array.isArray(schema)) {
93
+ throw new Error(`Database "${database.key}" must have a schema object`);
94
+ }
95
+ return schema;
96
+ }
97
+ function isRelationProperty(value) {
98
+ return typeof value === "object" && value !== null && "type" in value && value.type === "relation" && "database" in value;
99
+ }
100
+ class DatabaseCollector {
101
+ #keys = /* @__PURE__ */ new Map();
102
+ #byKey = /* @__PURE__ */ new Map();
103
+ #order = [];
104
+ collect(value, referencedFrom) {
105
+ const known = this.#keys.get(value);
106
+ if (known !== void 0) return known;
107
+ const database = asDatabase(value);
108
+ if (!database) {
109
+ throw new Error(
110
+ `${referencedFrom}: expected a database created with createDatabase(...)`
111
+ );
112
+ }
113
+ const duplicate = this.#byKey.get(database.key);
114
+ if (duplicate && duplicate !== database) {
115
+ throw new Error(`Two distinct databases use the key "${database.key}"`);
116
+ }
117
+ this.#keys.set(database, database.key);
118
+ this.#byKey.set(database.key, database);
119
+ this.#order.push(database.key);
120
+ for (const [propertyName, property] of Object.entries(schemaProperties(database))) {
121
+ if (isRelationProperty(property)) {
122
+ this.collect(
123
+ property.database,
124
+ `relation property "${propertyName}" of database "${database.key}"`
125
+ );
126
+ }
127
+ }
128
+ return database.key;
129
+ }
130
+ get(key) {
131
+ return this.#byKey.get(key);
132
+ }
133
+ entries() {
134
+ return this.#order.map((key) => {
135
+ const database = this.#byKey.get(key);
136
+ const properties = {};
137
+ for (const [propertyName, property] of Object.entries(schemaProperties(database))) {
138
+ if (isRelationProperty(property)) {
139
+ const { database: target, ...rest } = property;
140
+ properties[propertyName] = {
141
+ ...rest,
142
+ relatedDatabaseKey: this.#keys.get(target)
143
+ };
144
+ } else {
145
+ properties[propertyName] = property;
146
+ }
147
+ }
148
+ return {
149
+ key,
150
+ config: {
151
+ ...typeof database.config.initialTitle === "string" ? { initialTitle: database.config.initialTitle } : {},
152
+ schema: {
153
+ properties,
154
+ ...database.config.databaseIcon === void 0 ? {} : { databaseIcon: database.config.databaseIcon },
155
+ ...database.config.subItems === void 0 ? {} : { subItems: database.config.subItems }
156
+ }
157
+ }
158
+ };
159
+ });
160
+ }
161
+ }
36
162
  async function extractManifest(bundlePath, capabilities) {
163
+ clearRegisteredPacers();
37
164
  const bundle = await importBundle(bundlePath);
38
- const capabilityRegistry = getCapabilityRegistry(bundle);
165
+ const registry = getCapabilityRegistry(bundle);
166
+ const databases = new DatabaseCollector();
167
+ const primaryKeyByDatabase = /* @__PURE__ */ new Map();
39
168
  const capabilityEntries = capabilities.map((capability) => {
40
- const value = capabilityRegistry.workflow?.[capability.key];
41
- const workflow = asWorkflow(value);
42
- if (!workflow) {
169
+ const tagged = asTagged(registry[capability.type]?.[capability.key]);
170
+ if (!tagged || tagged._tag !== capability.tag) {
43
171
  throw new Error(
44
- `${capability.sourcePath}: default export is not a workflow \u2014 files under src/workflows/ must default-export createWorkflow(...)`
172
+ `${capability.sourcePath}: default export is not a ${capability.type} \u2014 files under this directory must default-export create${capitalize(capability.type)}(...)`
45
173
  );
46
174
  }
47
- assertJsonSafe(workflow.config, `${capability.sourcePath} config`);
48
- return { type: "workflow", key: capability.key, config: workflow.config };
175
+ let config = { ...tagged.config };
176
+ if (capability.type === "sync") {
177
+ const databaseKey = databases.collect(config.database, capability.sourcePath);
178
+ const database = databases.get(databaseKey);
179
+ const primaryKey = config.primaryKeyProperty;
180
+ const property = typeof primaryKey === "string" ? schemaProperties(database)[primaryKey] : void 0;
181
+ if (typeof primaryKey !== "string" || typeof property !== "object" || property === null || !("type" in property) || property.type !== "title" && property.type !== "text") {
182
+ throw new Error(
183
+ `${capability.sourcePath}: primaryKey "${String(primaryKey)}" must name a title or rich-text property`
184
+ );
185
+ }
186
+ const existingPrimaryKey = primaryKeyByDatabase.get(databaseKey);
187
+ if (existingPrimaryKey !== void 0 && existingPrimaryKey !== primaryKey) {
188
+ throw new Error(
189
+ `Syncs for database "${databaseKey}" disagree on its primary key: "${existingPrimaryKey}" and "${primaryKey}"`
190
+ );
191
+ }
192
+ primaryKeyByDatabase.set(databaseKey, primaryKey);
193
+ const { database: _database, ...rest } = config;
194
+ config = { ...rest, databaseKey };
195
+ }
196
+ assertJsonSafe(config, `${capability.sourcePath} config`);
197
+ return { type: capability.type, key: capability.key, config };
49
198
  });
199
+ const pacerEntries = registeredPacers().map((pacer) => ({
200
+ key: pacer.name,
201
+ config: pacer.config
202
+ }));
203
+ for (const pacer of pacerEntries) assertJsonSafe(pacer.config, `pacer "${pacer.key}" config`);
204
+ const databaseEntries = databases.entries();
205
+ for (const database of databaseEntries) {
206
+ assertJsonSafe(database.config, `database "${database.key}" config`);
207
+ }
50
208
  const manifest = JSON.parse(
51
209
  JSON.stringify({
210
+ $schema: APPS_MANIFEST_SCHEMA,
52
211
  sdkVersion: SDK_VERSION,
53
- databases: [],
212
+ databases: databaseEntries,
54
213
  xldbs: [],
55
- pacers: [],
214
+ pacers: pacerEntries,
56
215
  capabilities: capabilityEntries
57
216
  })
58
217
  );
@@ -68,8 +227,7 @@ async function importBundle(bundlePath) {
68
227
  return await import(`${pathToFileURL(bundlePath).href}?import=${importNonce++}`);
69
228
  } catch (error) {
70
229
  throw new Error(
71
- `Failed to import built app bundle. Workflow modules must be importable without secrets or network access \u2014 check for top-level code that throws.
72
- Caused by: ${error instanceof Error ? error.message : String(error)}`
230
+ `Failed to import built app bundle. Capability modules must be importable without secrets or network access. Caused by: ${error instanceof Error ? error.message : String(error)}`
73
231
  );
74
232
  }
75
233
  }
@@ -79,11 +237,8 @@ function getCapabilityRegistry(bundle) {
79
237
  }
80
238
  return bundle.capabilities;
81
239
  }
82
- function asWorkflow(value) {
83
- if (typeof value === "object" && value !== null && "_tag" in value && value._tag === "workflow" && "config" in value && typeof value.config === "object" && value.config !== null) {
84
- return value;
85
- }
86
- return void 0;
240
+ function capitalize(value) {
241
+ return `${value.slice(0, 1).toUpperCase()}${value.slice(1)}`;
87
242
  }
88
243
  function assertJsonSafe(value, path) {
89
244
  const reject = (reason) => {
@@ -91,13 +246,9 @@ function assertJsonSafe(value, path) {
91
246
  };
92
247
  switch (typeof value) {
93
248
  case "function":
94
- reject("function");
95
- break;
96
249
  case "symbol":
97
- reject("symbol");
98
- break;
99
250
  case "bigint":
100
- reject("bigint");
251
+ reject(typeof value);
101
252
  break;
102
253
  case "number":
103
254
  if (!Number.isFinite(value)) reject(String(value));
@@ -121,8 +272,7 @@ function assertJsonSafe(value, path) {
121
272
  reject(object.constructor?.name ?? "non-plain object");
122
273
  }
123
274
  for (const [key, property] of Object.entries(object)) {
124
- if (property === void 0) continue;
125
- assertJsonSafe(property, `${path}.${key}`);
275
+ if (property !== void 0) assertJsonSafe(property, `${path}.${key}`);
126
276
  }
127
277
  }
128
278
  export {
package/dist/context.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Client } from "@notionhq/client";
2
- const FIX_STEPS = "\n\nTo fix this:\n1. Create a personal access token at https://app.notion.com/developers/tokens\n2. Add NOTION_API_TOKEN=<your-token> to your .env file\n3. For deployed apps, run: ntn apps env push";
2
+ const LOCAL_FIX_STEPS = "\n\nFor local execution:\n1. Create a personal access token at https://app.notion.com/developers/tokens\n2. Add NOTION_API_TOKEN=<your-token> to your .env file\n\nDeployed apps receive Notion API credentials automatically. Do not push NOTION_API_TOKEN.";
3
3
  function missingTokenMessage() {
4
- return "NOTION_API_TOKEN is not set. context.notion requires an API token to make requests in workflow capabilities to Notion." + FIX_STEPS;
4
+ return "NOTION_API_TOKEN is not set. context.notion requires an API token when an app capability runs locally." + LOCAL_FIX_STEPS;
5
5
  }
6
6
  function createUnauthenticatedNotionProxy() {
7
7
  return new Proxy({}, {
@@ -0,0 +1,13 @@
1
+ import type { DatabaseSchema, DatabaseSchemaOptions } from "./schema.js";
2
+ export type DatabaseConfiguration<S extends DatabaseSchema = DatabaseSchema> = DatabaseSchemaOptions & {
3
+ initialTitle?: string;
4
+ schema: S;
5
+ };
6
+ export type Database<S extends DatabaseSchema = DatabaseSchema> = {
7
+ _tag: "database";
8
+ key: string;
9
+ config: DatabaseConfiguration<S>;
10
+ };
11
+ /** Declare a Notion database that a user will attach to this app. */
12
+ export declare function createDatabase<const S extends DatabaseSchema>(key: string, config: DatabaseConfiguration<S>): Database<S>;
13
+ //# sourceMappingURL=database.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"database.d.ts","sourceRoot":"","sources":["../src/database.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAC;AAEzE,MAAM,MAAM,qBAAqB,CAAC,CAAC,SAAS,cAAc,GAAG,cAAc,IAC1E,qBAAqB,GAAG;IACvB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,CAAC,CAAC;CACV,CAAC;AAEH,MAAM,MAAM,QAAQ,CAAC,CAAC,SAAS,cAAc,GAAG,cAAc,IAAI;IACjE,IAAI,EAAE,UAAU,CAAC;IACjB,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,qBAAqB,CAAC,CAAC,CAAC,CAAC;CACjC,CAAC;AAEF,qEAAqE;AACrE,wBAAgB,cAAc,CAAC,KAAK,CAAC,CAAC,SAAS,cAAc,EAC5D,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,qBAAqB,CAAC,CAAC,CAAC,GAC9B,QAAQ,CAAC,CAAC,CAAC,CAMb"}
@@ -0,0 +1,9 @@
1
+ function createDatabase(key, config) {
2
+ if (typeof key !== "string" || key.length === 0) {
3
+ throw new Error("Database key must be a non-empty string");
4
+ }
5
+ return { _tag: "database", key, config };
6
+ }
7
+ export {
8
+ createDatabase
9
+ };
package/dist/error.d.ts CHANGED
@@ -5,4 +5,11 @@ export declare class ExecutionError extends Error {
5
5
  readonly cause: unknown;
6
6
  constructor(cause: unknown);
7
7
  }
8
+ /** Tell the platform that an external service asked the sync to slow down. */
9
+ export declare class RateLimitError extends ExecutionError {
10
+ readonly retryAfter: number | undefined;
11
+ constructor(options?: {
12
+ retryAfter?: number;
13
+ });
14
+ }
8
15
  //# sourceMappingURL=error.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"error.d.ts","sourceRoot":"","sources":["../src/error.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,qBAAa,cAAe,SAAQ,KAAK;IACxC,SAAkB,KAAK,EAAE,OAAO,CAAC;gBAErB,KAAK,EAAE,OAAO;CAK1B"}
1
+ {"version":3,"file":"error.d.ts","sourceRoot":"","sources":["../src/error.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,qBAAa,cAAe,SAAQ,KAAK;IACxC,SAAkB,KAAK,EAAE,OAAO,CAAC;gBAErB,KAAK,EAAE,OAAO;CAK1B;AAED,8EAA8E;AAC9E,qBAAa,cAAe,SAAQ,cAAc;IACjD,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,CAAC;gBAE5B,OAAO,CAAC,EAAE;QAAE,UAAU,CAAC,EAAE,MAAM,CAAA;KAAE;CAK7C"}
package/dist/error.js CHANGED
@@ -6,6 +6,15 @@ class ExecutionError extends Error {
6
6
  this.cause = cause;
7
7
  }
8
8
  }
9
+ class RateLimitError extends ExecutionError {
10
+ retryAfter;
11
+ constructor(options) {
12
+ super("Rate limited by external API");
13
+ this.name = "RateLimitError";
14
+ this.retryAfter = options?.retryAfter;
15
+ }
16
+ }
9
17
  export {
10
- ExecutionError
18
+ ExecutionError,
19
+ RateLimitError
11
20
  };