@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/docs/BUILD.md CHANGED
@@ -43,6 +43,7 @@ The workflow-only manifest retains the platform's existing resource fields as em
43
43
 
44
44
  ```json
45
45
  {
46
+ "$schema": "notion:apps-manifest:v1",
46
47
  "sdkVersion": "0.0.1",
47
48
  "databases": [],
48
49
  "xldbs": [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@notionhq/apps",
3
- "version": "0.0.4",
3
+ "version": "0.0.7",
4
4
  "description": "An SDK for building workflow apps for Notion",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -19,10 +19,34 @@
19
19
  "types": "./dist/context.d.ts",
20
20
  "default": "./dist/context.js"
21
21
  },
22
+ "./builder": {
23
+ "types": "./dist/builder.d.ts",
24
+ "default": "./dist/builder.js"
25
+ },
26
+ "./database": {
27
+ "types": "./dist/database.d.ts",
28
+ "default": "./dist/database.js"
29
+ },
22
30
  "./error": {
23
31
  "types": "./dist/error.d.ts",
24
32
  "default": "./dist/error.js"
25
33
  },
34
+ "./pacer": {
35
+ "types": "./dist/pacer.d.ts",
36
+ "default": "./dist/pacer.js"
37
+ },
38
+ "./schema": {
39
+ "types": "./dist/schema.d.ts",
40
+ "default": "./dist/schema.js"
41
+ },
42
+ "./sync": {
43
+ "types": "./dist/sync.d.ts",
44
+ "default": "./dist/sync.js"
45
+ },
46
+ "./types": {
47
+ "types": "./dist/types.d.ts",
48
+ "default": "./dist/types.js"
49
+ },
26
50
  "./manifest": {
27
51
  "types": "./dist/manifest.d.ts",
28
52
  "default": "./dist/manifest.js"
@@ -40,6 +64,10 @@
40
64
  "access": "public"
41
65
  },
42
66
  "scripts": {
67
+ "fmt": "oxfmt",
68
+ "fmt:check": "oxfmt --check",
69
+ "lint": "oxlint --no-error-on-unmatched-pattern",
70
+ "lint:fix": "oxlint --fix --no-error-on-unmatched-pattern",
43
71
  "build": "rm -rf dist && pnpm run build:types && pnpm run build:js",
44
72
  "build:types": "tsc",
45
73
  "build:js": "tsx scripts/build.ts",
@@ -60,6 +88,8 @@
60
88
  "@types/node": "^26.0.0",
61
89
  "concurrently": "^9.2.1",
62
90
  "esbuild": "^0.27.3",
91
+ "oxfmt": "^0.65.0",
92
+ "oxlint": "^1.80.0",
63
93
  "tsx": "^4.20.6",
64
94
  "typescript": "^5.9.3",
65
95
  "vitest": "^4.0.8"
@@ -70,5 +100,6 @@
70
100
  "engines": {
71
101
  "node": ">=26.0.0",
72
102
  "pnpm": ">=11.24.0"
73
- }
103
+ },
104
+ "packageManager": "pnpm@11.24.0"
74
105
  }
@@ -0,0 +1,81 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { Builder } from "./builder.js";
4
+
5
+ describe("Builder", () => {
6
+ it("builds text, number, select, people, and file values", () => {
7
+ expect(Builder.richText("hello")).toEqual([["hello"]]);
8
+ expect(Builder.number(42)).toEqual([["42"]]);
9
+ expect(Builder.number(Number.NaN)).toEqual([]);
10
+ expect(Builder.select("Open")).toEqual([["Open"]]);
11
+ expect(Builder.multiSelect("Bug", "Urgent")).toEqual([["Bug,Urgent"]]);
12
+ expect(Builder.status("In progress")).toEqual([["In progress"]]);
13
+ expect(Builder.people("ada@example.com")).toEqual([{ email: "ada@example.com" }]);
14
+ expect(Builder.file("https://example.com/file.pdf", "File")).toEqual([
15
+ ["File", [["a", "https://example.com/file.pdf"]]],
16
+ ]);
17
+ });
18
+
19
+ it("builds dates and date ranges", () => {
20
+ expect(Builder.date("2026-08-26")).toEqual([
21
+ ["‣", [["d", { type: "date", start_date: "2026-08-26" }]]],
22
+ ]);
23
+ expect(Builder.dateTime("2026-08-26T10:30:00Z", "America/New_York")).toEqual([
24
+ [
25
+ "‣",
26
+ [
27
+ [
28
+ "d",
29
+ {
30
+ type: "datetime",
31
+ start_date: "2026-08-26",
32
+ start_time: "10:30",
33
+ time_zone: "America/New_York",
34
+ },
35
+ ],
36
+ ],
37
+ ],
38
+ ]);
39
+ expect(Builder.dateRange("2026-08-26", "2026-08-27")).toEqual([
40
+ [
41
+ "‣",
42
+ [
43
+ [
44
+ "d",
45
+ {
46
+ type: "daterange",
47
+ start_date: "2026-08-26",
48
+ end_date: "2026-08-27",
49
+ },
50
+ ],
51
+ ],
52
+ ],
53
+ ]);
54
+ });
55
+
56
+ it("builds places, relations, icons, and covers", () => {
57
+ expect(Builder.place({ lat: 40.7128, lon: -74.006, name: "New York" })).toEqual({
58
+ lat: 40.7128,
59
+ lon: -74.006,
60
+ name: "New York",
61
+ });
62
+ expect(Builder.relation("project-1")).toEqual({
63
+ type: "primaryKey",
64
+ value: "project-1",
65
+ });
66
+ expect(Builder.notionIcon("checkmark", "green")).toEqual({
67
+ type: "notion",
68
+ icon: "checkmark",
69
+ color: "green",
70
+ });
71
+ expect(Builder.imageIcon("https://example.com/icon.png")).toEqual({
72
+ type: "image",
73
+ url: "https://example.com/icon.png",
74
+ });
75
+ expect(Builder.imageCover("https://example.com/cover.png", 0.25)).toEqual({
76
+ type: "image",
77
+ url: "https://example.com/cover.png",
78
+ position: 0.25,
79
+ });
80
+ });
81
+ });
package/src/builder.ts ADDED
@@ -0,0 +1,99 @@
1
+ import type {
2
+ Cover,
3
+ DateValue,
4
+ Icon,
5
+ NoticonColor,
6
+ PeopleValue,
7
+ PlaceValue,
8
+ RelationReference,
9
+ TextValue,
10
+ } from "./types.js";
11
+
12
+ function validateDateString(value: string): void {
13
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || Number.isNaN(new Date(value).getTime())) {
14
+ throw new Error(`Invalid date: ${value}. Expected YYYY-MM-DD.`);
15
+ }
16
+ }
17
+
18
+ function dateToken(value: DateValue): TextValue {
19
+ return [["‣", [["d", value]]]];
20
+ }
21
+
22
+ function parseISODateTime(value: string): { date: string; time: string } {
23
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value) || Number.isNaN(new Date(value).getTime())) {
24
+ throw new Error(`Invalid ISO 8601 datetime: ${value}`);
25
+ }
26
+ return { date: value.slice(0, 10), time: value.slice(11, 16) };
27
+ }
28
+
29
+ /** Build property values returned by a sync handler. */
30
+ export const Builder = {
31
+ richText: (content: string): TextValue => [[content]],
32
+ text: (content: string): TextValue => [[content]],
33
+ title: (content: string): TextValue => [[content]],
34
+ url: (url: string): TextValue => [[url]],
35
+ email: (email: string): TextValue => [[email]],
36
+ phoneNumber: (phone: string): TextValue => [[phone]],
37
+ checkbox: (checked: boolean): TextValue => (checked ? [["Yes"]] : [["No"]]),
38
+ file: (url: string, name?: string): TextValue => [[name ?? url, [["a", url]]]],
39
+ number: (value: number): TextValue => (Number.isNaN(value) ? [] : [[String(value)]]),
40
+ date(value: string): TextValue {
41
+ validateDateString(value);
42
+ return dateToken({ type: "date", start_date: value });
43
+ },
44
+ dateTime(value: string, timeZone?: string): TextValue {
45
+ const parsed = parseISODateTime(value);
46
+ return dateToken({
47
+ type: "datetime",
48
+ start_date: parsed.date,
49
+ start_time: parsed.time,
50
+ ...(timeZone === undefined ? {} : { time_zone: timeZone }),
51
+ });
52
+ },
53
+ dateRange(startDate: string, endDate: string): TextValue {
54
+ validateDateString(startDate);
55
+ validateDateString(endDate);
56
+ return dateToken({ type: "daterange", start_date: startDate, end_date: endDate });
57
+ },
58
+ dateTimeRange(startValue: string, endValue: string, timeZone?: string): TextValue {
59
+ const start = parseISODateTime(startValue);
60
+ const end = parseISODateTime(endValue);
61
+ return dateToken({
62
+ type: "datetimerange",
63
+ start_date: start.date,
64
+ start_time: start.time,
65
+ end_date: end.date,
66
+ end_time: end.time,
67
+ ...(timeZone === undefined ? {} : { time_zone: timeZone }),
68
+ });
69
+ },
70
+ link: (text: string, url: string): TextValue => [[text, [["a", url]]]],
71
+ select: (value: string): TextValue => [[value]],
72
+ multiSelect: (...values: string[]): TextValue =>
73
+ values.length === 0 ? [] : [[values.join(",")]],
74
+ status: (value: string): TextValue => [[value]],
75
+ people: (...emails: string[]): PeopleValue => emails.map((email) => ({ email })),
76
+ place(value: PlaceValue): PlaceValue {
77
+ if (!Number.isFinite(value.lat) || !Number.isFinite(value.lon)) {
78
+ throw new Error("Place value must have numeric lat and lon coordinates");
79
+ }
80
+ return value;
81
+ },
82
+ relation: (primaryKey: string): RelationReference => ({
83
+ type: "primaryKey",
84
+ value: primaryKey,
85
+ }),
86
+ emojiIcon: (value: string): Icon => ({ type: "emoji", value }),
87
+ notionIcon: (icon: string, color: NoticonColor = "gray"): Icon => ({
88
+ type: "notion",
89
+ icon,
90
+ color,
91
+ }),
92
+ imageIcon: (url: string): Icon => ({ type: "image", url }),
93
+ imageCover(url: string, position = 0.5): Cover {
94
+ if (!Number.isFinite(position) || position < 0 || position > 1) {
95
+ throw new Error("Cover position must be between 0 and 1");
96
+ }
97
+ return { type: "image", url, position };
98
+ },
99
+ };
@@ -34,10 +34,49 @@ afterEach(async () => {
34
34
  });
35
35
 
36
36
  describe("buildApp", () => {
37
+ it("builds a sync manifest with an attached database contract", async () => {
38
+ const { manifest } = await buildApp(example("sync"));
39
+
40
+ expect(manifest).toEqual({
41
+ $schema: "notion:apps-manifest:v1",
42
+ sdkVersion: SDK_VERSION,
43
+ databases: [
44
+ {
45
+ key: "issues",
46
+ config: {
47
+ initialTitle: "GitHub Issues",
48
+ schema: {
49
+ properties: {
50
+ Name: { type: "title" },
51
+ "GitHub ID": { type: "text" },
52
+ Closed: { type: "checkbox" },
53
+ },
54
+ },
55
+ },
56
+ },
57
+ ],
58
+ xldbs: [],
59
+ pacers: [],
60
+ capabilities: [
61
+ {
62
+ type: "sync",
63
+ key: "issues",
64
+ config: {
65
+ databaseKey: "issues",
66
+ primaryKeyProperty: "GitHub ID",
67
+ mode: "incremental",
68
+ schedule: { type: "interval", intervalMs: 3_600_000 },
69
+ },
70
+ },
71
+ ],
72
+ });
73
+ });
74
+
37
75
  it("builds a workflow bundle and manifest", async () => {
38
76
  const { manifest, bundlePath, manifestPath } = await buildApp(example("workflow"));
39
77
 
40
78
  expect(manifest).toEqual({
79
+ $schema: "notion:apps-manifest:v1",
41
80
  sdkVersion: SDK_VERSION,
42
81
  databases: [],
43
82
  xldbs: [],
@@ -58,6 +97,76 @@ describe("buildApp", () => {
58
97
  expect(JSON.parse(await fs.promises.readFile(manifestPath, "utf8"))).toEqual(manifest);
59
98
  });
60
99
 
100
+ it("collects relation databases and a shared pacer once", async () => {
101
+ const { manifest } = await buildApp(fixture("shared-primary-key"));
102
+
103
+ expect(manifest.databases).toEqual([
104
+ {
105
+ key: "issues",
106
+ config: {
107
+ schema: {
108
+ properties: {
109
+ Name: { type: "title" },
110
+ "GitHub ID": { type: "text" },
111
+ Project: {
112
+ type: "relation",
113
+ config: { twoWay: false },
114
+ relatedDatabaseKey: "projects",
115
+ },
116
+ },
117
+ },
118
+ },
119
+ },
120
+ {
121
+ key: "projects",
122
+ config: {
123
+ schema: {
124
+ properties: {
125
+ Name: { type: "title" },
126
+ "Project ID": { type: "text" },
127
+ },
128
+ },
129
+ },
130
+ },
131
+ ]);
132
+ expect(manifest.pacers).toEqual([
133
+ {
134
+ key: "issues-api",
135
+ config: { allowedRequests: 10, intervalMs: 1_000 },
136
+ },
137
+ ]);
138
+ expect(manifest.capabilities).toEqual([
139
+ {
140
+ type: "sync",
141
+ key: "closed",
142
+ config: {
143
+ databaseKey: "issues",
144
+ primaryKeyProperty: "GitHub ID",
145
+ },
146
+ },
147
+ {
148
+ type: "sync",
149
+ key: "open",
150
+ config: {
151
+ databaseKey: "issues",
152
+ primaryKeyProperty: "GitHub ID",
153
+ },
154
+ },
155
+ ]);
156
+ });
157
+
158
+ it("rejects syncs that disagree on a shared database primary key", async () => {
159
+ await expect(buildApp(fixture("conflicting-primary-key"))).rejects.toThrow(
160
+ /Syncs for database "issues" disagree on its primary key/,
161
+ );
162
+ });
163
+
164
+ it("rejects distinct database objects with the same key", async () => {
165
+ await expect(buildApp(fixture("duplicate-database-name"))).rejects.toThrow(
166
+ /Two distinct databases use the key "shared"/,
167
+ );
168
+ });
169
+
61
170
  it("rejects a workflow file whose default export is not a workflow", async () => {
62
171
  await expect(buildApp(fixture("wrong-tag"))).rejects.toThrow(
63
172
  /notWorkflow\.ts.*default export is not a workflow/,
package/src/cli/build.ts CHANGED
@@ -25,7 +25,7 @@ export async function buildApp(projectRoot: string): Promise<BuildResult> {
25
25
  if (capabilities.length === 0) {
26
26
  throw new Error(
27
27
  `No capabilities found in ${projectRoot}. ` +
28
- `Add a workflow file under src/workflows/<name>.ts that default-exports createWorkflow(...).`,
28
+ `Add a workflow under src/workflows/ or a sync under src/syncs/.`,
29
29
  );
30
30
  }
31
31
 
@@ -18,4 +18,19 @@ describe("generateEntry", () => {
18
18
  expect(entry).toContain(`"onPageCreated": capability_0,`);
19
19
  expect(entry).toContain("export async function run(");
20
20
  });
21
+
22
+ it("registers syncs in their own capability group", () => {
23
+ const entry = generateEntry([
24
+ {
25
+ type: "sync",
26
+ tag: "sync",
27
+ key: "issues",
28
+ sourcePath: "src/syncs/issues.ts",
29
+ },
30
+ ]);
31
+
32
+ expect(entry).toContain(`import capability_0 from "../src/syncs/issues.ts";`);
33
+ expect(entry).toContain(`"sync": {`);
34
+ expect(entry).toContain(`"issues": capability_0,`);
35
+ });
21
36
  });
@@ -9,7 +9,7 @@ const EXAMPLES = fileURLToPath(new URL("../../examples/", import.meta.url));
9
9
  const FIXTURES = fileURLToPath(new URL("../../test/fixtures/", import.meta.url));
10
10
 
11
11
  describe("discoverCapabilities", () => {
12
- it("discovers only workflow files", async () => {
12
+ it("discovers workflow files", async () => {
13
13
  await expect(discoverCapabilities(path.join(EXAMPLES, "workflow"))).resolves.toEqual([
14
14
  {
15
15
  type: "workflow",
@@ -20,7 +20,18 @@ describe("discoverCapabilities", () => {
20
20
  ]);
21
21
  });
22
22
 
23
- it("returns nothing when no workflow directory exists", async () => {
23
+ it("discovers sync files", async () => {
24
+ await expect(discoverCapabilities(path.join(EXAMPLES, "sync"))).resolves.toEqual([
25
+ {
26
+ type: "sync",
27
+ tag: "sync",
28
+ key: "issues",
29
+ sourcePath: path.join("src", "syncs", "issues.ts"),
30
+ },
31
+ ]);
32
+ });
33
+
34
+ it("returns nothing when no capability directory exists", async () => {
24
35
  expect(await discoverCapabilities(path.join(FIXTURES, "empty"))).toEqual([]);
25
36
  });
26
37
  });
@@ -6,7 +6,7 @@ import type { CapabilityType } from "../manifest.js";
6
6
  /**
7
7
  * A capability type the build tool knows how to discover: which directory
8
8
  * under `src/` its files live in, and the `_tag` its default exports must
9
- * carry. This SDK intentionally registers workflows only.
9
+ * carry. This SDK registers workflows and syncs.
10
10
  */
11
11
  export type CapabilityTypeDefinition = {
12
12
  type: CapabilityType;
@@ -16,6 +16,7 @@ export type CapabilityTypeDefinition = {
16
16
 
17
17
  export const CAPABILITY_TYPES: readonly CapabilityTypeDefinition[] = [
18
18
  { type: "workflow", directory: "workflows", tag: "workflow" },
19
+ { type: "sync", directory: "syncs", tag: "sync" },
19
20
  ];
20
21
 
21
22
  /**
@@ -35,7 +36,7 @@ export type DiscoveredCapability = {
35
36
  * `src/<directory>/` for each registered capability type. Subdirectories are
36
37
  * not searched — they hold plain modules, not capabilities.
37
38
  *
38
- * Only workflow files directly under `src/workflows/` are discovered.
39
+ * Workflow and sync files are discovered from their matching directories.
39
40
  */
40
41
  export async function discoverCapabilities(projectRoot: string): Promise<DiscoveredCapability[]> {
41
42
  const discovered: DiscoveredCapability[] = [];