@notionhq/apps 0.0.9 → 0.0.11

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/dist/cli/build.d.ts +2 -2
  2. package/dist/cli/build.d.ts.map +1 -1
  3. package/dist/cli/build.js +44 -2
  4. package/dist/cli/codegen.d.ts +5 -0
  5. package/dist/cli/codegen.d.ts.map +1 -1
  6. package/dist/cli/codegen.js +15 -1
  7. package/dist/cli/emit-manifest.d.ts +3 -0
  8. package/dist/cli/emit-manifest.d.ts.map +1 -1
  9. package/dist/cli/emit-manifest.js +12 -1
  10. package/dist/notion-as-code/custom-agent.d.ts +18 -0
  11. package/dist/notion-as-code/custom-agent.d.ts.map +1 -0
  12. package/dist/notion-as-code/custom-agent.js +13 -0
  13. package/dist/notion-as-code/database.d.ts +44 -0
  14. package/dist/notion-as-code/database.d.ts.map +1 -0
  15. package/dist/notion-as-code/database.js +68 -0
  16. package/dist/notion-as-code/date.d.ts +9 -0
  17. package/dist/notion-as-code/date.d.ts.map +1 -0
  18. package/dist/notion-as-code/date.js +69 -0
  19. package/dist/notion-as-code/handles.d.ts +32 -0
  20. package/dist/notion-as-code/handles.d.ts.map +1 -0
  21. package/dist/notion-as-code/handles.js +34 -0
  22. package/dist/notion-as-code/index.d.ts +30 -0
  23. package/dist/notion-as-code/index.d.ts.map +1 -0
  24. package/dist/notion-as-code/index.js +8 -0
  25. package/dist/notion-as-code/intents.d.ts +115 -0
  26. package/dist/notion-as-code/intents.d.ts.map +1 -0
  27. package/dist/notion-as-code/intents.js +6 -0
  28. package/dist/notion-as-code/page.d.ts +22 -0
  29. package/dist/notion-as-code/page.d.ts.map +1 -0
  30. package/dist/notion-as-code/page.js +31 -0
  31. package/dist/notion-as-code/provisioning.d.ts +22 -0
  32. package/dist/notion-as-code/provisioning.d.ts.map +1 -0
  33. package/dist/notion-as-code/provisioning.js +25 -0
  34. package/dist/notion-as-code/recorder.d.ts +10 -0
  35. package/dist/notion-as-code/recorder.d.ts.map +1 -0
  36. package/dist/notion-as-code/recorder.js +21 -0
  37. package/dist/notion-as-code/resource.d.ts +6 -0
  38. package/dist/notion-as-code/resource.d.ts.map +1 -0
  39. package/dist/notion-as-code/resource.js +19 -0
  40. package/dist/notion-as-code/schema.d.ts +63 -0
  41. package/dist/notion-as-code/schema.d.ts.map +1 -0
  42. package/dist/notion-as-code/schema.js +94 -0
  43. package/dist/notion-as-code/teamspace.d.ts +18 -0
  44. package/dist/notion-as-code/teamspace.d.ts.map +1 -0
  45. package/dist/notion-as-code/teamspace.js +30 -0
  46. package/dist/sync.d.ts +15 -2
  47. package/dist/sync.d.ts.map +1 -1
  48. package/dist/sync.js +14 -3
  49. package/package.json +9 -1
  50. package/src/cli/build.test.ts +91 -1
  51. package/src/cli/build.ts +61 -6
  52. package/src/cli/codegen.ts +21 -0
  53. package/src/cli/discover.test.ts +6 -0
  54. package/src/cli/emit-manifest.ts +18 -0
  55. package/src/notion-as-code/custom-agent.ts +30 -0
  56. package/src/notion-as-code/database.ts +143 -0
  57. package/src/notion-as-code/date.ts +91 -0
  58. package/src/notion-as-code/handles.ts +36 -0
  59. package/src/notion-as-code/index.ts +16 -0
  60. package/src/notion-as-code/intents.ts +127 -0
  61. package/src/notion-as-code/page.ts +61 -0
  62. package/src/notion-as-code/provisioning.ts +50 -0
  63. package/src/notion-as-code/recorder.ts +26 -0
  64. package/src/notion-as-code/resource.ts +19 -0
  65. package/src/notion-as-code/schema.ts +149 -0
  66. package/src/notion-as-code/teamspace.ts +50 -0
  67. package/src/sync.ts +63 -5
@@ -0,0 +1,50 @@
1
+ import type { InfraAsCodeIntent } from "./intents.js";
2
+
3
+ export const APPS_PROVISIONING_SCHEMA = "notion:apps-provisioning:v1";
4
+
5
+ /** Provisioning intents recorded while evaluating the build-only metadata bundle. */
6
+ export type RecordedProvisioning = {
7
+ intents: InfraAsCodeIntent[];
8
+ };
9
+
10
+ /** Teamspaces are always created in the Apps workspace, so Apps supplies their parent. */
11
+ export type ProvisioningIntent =
12
+ | Omit<Extract<InfraAsCodeIntent, { type: "teamspace" }>, "parent">
13
+ | Exclude<InfraAsCodeIntent, { type: "teamspace" }>;
14
+
15
+ export type ProvisioningArtifact = {
16
+ $schema: typeof APPS_PROVISIONING_SCHEMA;
17
+ version: 1;
18
+ intents: readonly ProvisioningIntent[];
19
+ };
20
+
21
+ /** Convert recorded declarations into the server-facing provisioning artifact. */
22
+ export function createProvisioningArtifact(
23
+ intents: readonly InfraAsCodeIntent[],
24
+ ): ProvisioningArtifact {
25
+ const provisioningIntents: ProvisioningIntent[] = [];
26
+ for (const intent of intents) {
27
+ if (intent.type === "teamspace") {
28
+ const { parent: _parent, ...teamspace } = intent;
29
+ provisioningIntents.push(teamspace);
30
+ } else {
31
+ provisioningIntents.push(intent);
32
+ }
33
+ }
34
+
35
+ return {
36
+ $schema: APPS_PROVISIONING_SCHEMA,
37
+ version: 1,
38
+ intents: provisioningIntents,
39
+ };
40
+ }
41
+
42
+ /** Check that a metadata bundle exported the recorder output. */
43
+ export function isRecordedProvisioning(value: unknown): value is RecordedProvisioning {
44
+ return (
45
+ typeof value === "object" &&
46
+ value !== null &&
47
+ "intents" in value &&
48
+ Array.isArray(value.intents)
49
+ );
50
+ }
@@ -0,0 +1,26 @@
1
+ /** Records Notion-as-Code declarations while the build-only metadata bundle evaluates. */
2
+ import type { InfraAsCodeIntent } from "./intents.js";
3
+ import type { RecordedProvisioning } from "./provisioning.js";
4
+
5
+ let activeRecorder: RecordedProvisioning | undefined;
6
+
7
+ /** Start collecting declarations from the metadata bundle. */
8
+ export function startMetadataRecording(): RecordedProvisioning {
9
+ const recorder: RecordedProvisioning = { intents: [] };
10
+ activeRecorder = recorder;
11
+ return recorder;
12
+ }
13
+
14
+ /** Finish the active metadata collection and return its declarations. */
15
+ export function finishMetadataRecording(recorder: RecordedProvisioning): RecordedProvisioning {
16
+ if (activeRecorder !== recorder) {
17
+ throw new Error("Notion-as-Code metadata recorder was replaced before build completed");
18
+ }
19
+ activeRecorder = undefined;
20
+ return recorder;
21
+ }
22
+
23
+ /** Record one declaration when metadata collection is active. */
24
+ export function recordIntent(intent: InfraAsCodeIntent): void {
25
+ activeRecorder?.intents.push(intent);
26
+ }
@@ -0,0 +1,19 @@
1
+ import { APPS_RESERVED_RESOURCE_ID_PREFIX, type Parent } from "./intents.js";
2
+
3
+ /** Validate parent references accepted by Apps declarations. */
4
+ export function assertParent(parent: Parent): void {
5
+ if (parent.type !== "resourceId") {
6
+ throw new Error("Notion-as-Code parents must reference a resourceId");
7
+ }
8
+ assertUserResourceId(parent.resourceId);
9
+ }
10
+
11
+ /** Reject empty or runtime-reserved resource IDs in user declarations. */
12
+ export function assertUserResourceId(resourceId: string): void {
13
+ if (resourceId.length === 0) {
14
+ throw new Error("Notion-as-Code resourceId must be a non-empty string");
15
+ }
16
+ if (resourceId.startsWith(APPS_RESERVED_RESOURCE_ID_PREFIX)) {
17
+ throw new Error(`Resource ID "${resourceId}" uses the reserved Apps prefix`);
18
+ }
19
+ }
@@ -0,0 +1,149 @@
1
+ // TODO: Converge on one schema. Until then, keep this translation layer aligned with Notion-as-Code.
2
+ import { createDatabase, type Database } from "../database.js";
3
+ import type { DatabaseSchema, PropertyConfiguration } from "../schema.js";
4
+ import type { SelectOption, StatusGroup } from "../types.js";
5
+ import type {
6
+ DataSourceDefinition,
7
+ NotionAsCodeProperty,
8
+ NotionAsCodeSelectOption,
9
+ NotionAsCodeStatusOption,
10
+ } from "./intents.js";
11
+
12
+ type AppsPropertyFor<Property extends NotionAsCodeProperty> = Property extends { type: "title" }
13
+ ? { type: "title" }
14
+ : Property extends { type: "text" }
15
+ ? { type: "text" }
16
+ : Property extends { type: "number" }
17
+ ? { type: "number" }
18
+ : Property extends { type: "select" }
19
+ ? { type: "select"; options: SelectOption[] }
20
+ : Property extends { type: "multi_select" }
21
+ ? { type: "multi_select"; options: SelectOption[] }
22
+ : Property extends { type: "status" }
23
+ ? { type: "status"; groups: StatusGroup[] }
24
+ : Property extends { type: "date" }
25
+ ? { type: "date" }
26
+ : Property extends { type: "checkbox" }
27
+ ? { type: "checkbox" }
28
+ : Property extends { type: "url" }
29
+ ? { type: "url" }
30
+ : Property extends { type: "email" }
31
+ ? { type: "email" }
32
+ : Property extends { type: "phone_number" }
33
+ ? { type: "phone_number" }
34
+ : Property extends { type: "file" }
35
+ ? { type: "file" }
36
+ : PropertyConfiguration;
37
+
38
+ export type AppsSchemaForProperties<Properties extends readonly NotionAsCodeProperty[]> = {
39
+ [Property in Properties[number] as Property["name"]]: AppsPropertyFor<Property>;
40
+ };
41
+
42
+ /** Convert a Notion-as-Code data source into the Apps database used by sync handlers. */
43
+ export function createAppsDatabase(
44
+ databaseKey: string,
45
+ dataSource: DataSourceDefinition,
46
+ ): Database {
47
+ const properties: DatabaseSchema = {};
48
+ const propertyResourceIds = new Set<string>();
49
+ let titleCount = 0;
50
+
51
+ for (const property of dataSource.properties) {
52
+ if (property.resourceId.length === 0) {
53
+ throw new Error(
54
+ `Data source "${dataSource.resourceId}" has a property without a resourceId`,
55
+ );
56
+ }
57
+ if (propertyResourceIds.has(property.resourceId)) {
58
+ throw new Error(
59
+ `Data source "${dataSource.resourceId}" has duplicate property resourceId "${property.resourceId}"`,
60
+ );
61
+ }
62
+ if (properties[property.name] !== undefined) {
63
+ throw new Error(
64
+ `Data source "${dataSource.resourceId}" has duplicate property name "${property.name}"`,
65
+ );
66
+ }
67
+ propertyResourceIds.add(property.resourceId);
68
+ if (property.type === "title") {
69
+ titleCount += 1;
70
+ }
71
+ properties[property.name] = toAppsProperty(dataSource.resourceId, property);
72
+ }
73
+
74
+ if (titleCount !== 1) {
75
+ throw new Error(
76
+ `Data source "${dataSource.resourceId}" must have exactly one title property to bind a sync`,
77
+ );
78
+ }
79
+
80
+ return createDatabase(databaseKey, {
81
+ initialTitle: dataSource.name,
82
+ schema: properties,
83
+ });
84
+ }
85
+
86
+ /** Map Notion-as-Code properties to Apps properties; their schemas differ for selects and status groups. */
87
+ function toAppsProperty(
88
+ dataSourceResourceId: string,
89
+ property: NotionAsCodeProperty,
90
+ ): PropertyConfiguration {
91
+ switch (property.type) {
92
+ case "title":
93
+ return { type: "title" };
94
+ case "text":
95
+ return { type: "text" };
96
+ case "number":
97
+ return { type: "number" };
98
+ case "select":
99
+ return { type: "select", options: (property.options ?? []).map(toAppsSelectOption) };
100
+ case "multi_select":
101
+ return {
102
+ type: "multi_select",
103
+ options: (property.options ?? []).map(toAppsSelectOption),
104
+ };
105
+ case "status":
106
+ return {
107
+ type: "status",
108
+ groups: [
109
+ { name: "To-do", options: property.options.todo.map(toAppsStatusOption) },
110
+ {
111
+ name: "In progress",
112
+ options: property.options.inProgress.map(toAppsStatusOption),
113
+ },
114
+ {
115
+ name: "Complete",
116
+ options: property.options.complete.map(toAppsStatusOption),
117
+ },
118
+ ],
119
+ };
120
+ case "date":
121
+ return { type: "date" };
122
+ case "checkbox":
123
+ return { type: "checkbox" };
124
+ case "url":
125
+ return { type: "url" };
126
+ case "email":
127
+ return { type: "email" };
128
+ case "phone_number":
129
+ return { type: "phone_number" };
130
+ case "file":
131
+ return { type: "file" };
132
+ default:
133
+ throw new Error(
134
+ `Data source "${dataSourceResourceId}" property "${property.name}" uses unsupported sync type "${property.type}"`,
135
+ );
136
+ }
137
+ }
138
+
139
+ function toAppsSelectOption(option: NotionAsCodeSelectOption): SelectOption {
140
+ return option.color === undefined
141
+ ? { name: option.name }
142
+ : { name: option.name, color: option.color };
143
+ }
144
+
145
+ function toAppsStatusOption(option: NotionAsCodeStatusOption): SelectOption {
146
+ return option.color === undefined
147
+ ? { name: option.name }
148
+ : { name: option.name, color: option.color };
149
+ }
@@ -0,0 +1,50 @@
1
+ import {
2
+ APPS_WORKSPACE_RESOURCE_ID,
3
+ type DataSourceDefinition,
4
+ type NotionAsCodeIcon,
5
+ type ResourceId,
6
+ type TeamspaceIntent,
7
+ } from "./intents.js";
8
+ import { database, type ChildDatabaseArgs, type DatabaseHandle } from "./database.js";
9
+ import { createPage, type ChildPageArgs, type PageHandle } from "./page.js";
10
+ import { recordIntent } from "./recorder.js";
11
+ import { assertUserResourceId } from "./resource.js";
12
+
13
+ export type TeamspaceArgs = {
14
+ resourceId: ResourceId;
15
+ name: string;
16
+ accessLevel: "default" | "open" | "closed" | "private";
17
+ icon?: NotionAsCodeIcon;
18
+ description?: string;
19
+ };
20
+
21
+ export type TeamspaceHandle = {
22
+ readonly resourceId: string;
23
+ addDatabase: <const DataSources extends readonly DataSourceDefinition[]>(
24
+ args: ChildDatabaseArgs<DataSources>,
25
+ ) => DatabaseHandle<DataSources>;
26
+ addPage: (args: ChildPageArgs) => PageHandle;
27
+ };
28
+
29
+ /** Declare a teamspace that Apps automatically creates in its workspace. */
30
+ export function teamspace(args: TeamspaceArgs): TeamspaceHandle {
31
+ assertUserResourceId(args.resourceId);
32
+ recordIntent({
33
+ type: "teamspace",
34
+ ...args,
35
+ parent: { type: "resourceId", resourceId: APPS_WORKSPACE_RESOURCE_ID },
36
+ } satisfies { type: "teamspace" } & TeamspaceIntent);
37
+
38
+ return {
39
+ resourceId: args.resourceId,
40
+ addDatabase(databaseArgs) {
41
+ return database({
42
+ ...databaseArgs,
43
+ parent: { type: "resourceId", resourceId: args.resourceId },
44
+ });
45
+ },
46
+ addPage(pageArgs) {
47
+ return createPage(pageArgs, { type: "resourceId", resourceId: args.resourceId });
48
+ },
49
+ };
50
+ }
package/src/sync.ts CHANGED
@@ -2,6 +2,9 @@ import type { CapabilityContext } from "./context.js";
2
2
  import { createCapabilityContext } from "./context.js";
3
3
  import type { Database } from "./database.js";
4
4
  import { ExecutionError, RateLimitError } from "./error.js";
5
+ import type { DataSourceHandle } from "./notion-as-code/database.js";
6
+ import type { NotionAsCodeProperty } from "./notion-as-code/intents.js";
7
+ import type { AppsSchemaForProperties } from "./notion-as-code/schema.js";
5
8
  import { writeOutput } from "./output.js";
6
9
  import { initPacerState, pacerStates, type PacerEntry } from "./pacer.js";
7
10
  import { resolveRuntimeInput } from "./runtime-input.js";
@@ -82,7 +85,25 @@ export type SyncConfiguration<
82
85
  handler: (
83
86
  state: State | undefined,
84
87
  context: CapabilityContext,
85
- ) => Promise<SyncHandlerResult<S, PrimaryKey, State>>;
88
+ ) => Promise<SyncHandlerResult<NoInfer<S>, NoInfer<PrimaryKey>, State>>;
89
+ };
90
+
91
+ /** Configuration for a sync backed by a Notion-as-Code data source. */
92
+ type DataSourceSyncConfiguration<
93
+ Properties extends readonly NotionAsCodeProperty[],
94
+ PrimaryKey extends KeyPropertyName<AppsSchemaForProperties<Properties>>,
95
+ State,
96
+ > = {
97
+ dataSource: DataSourceHandle<Properties>;
98
+ primaryKey: PrimaryKey;
99
+ mode?: SyncMode;
100
+ schedule?: Schedule;
101
+ handler: (
102
+ state: State | undefined,
103
+ context: CapabilityContext,
104
+ ) => Promise<
105
+ SyncHandlerResult<NoInfer<AppsSchemaForProperties<Properties>>, NoInfer<PrimaryKey>, State>
106
+ >;
86
107
  };
87
108
 
88
109
  export type SyncRuntimeContext = {
@@ -98,6 +119,29 @@ type SyncDefinitionConfig = {
98
119
  schedule?: NormalizedSchedule;
99
120
  };
100
121
 
122
+ type SyncDatabaseConfiguration = { database: Database } | { dataSource: { database: Database } };
123
+
124
+ type RuntimeSyncChange =
125
+ | { type: "delete"; key: string }
126
+ | { type: "upsert"; key: string; properties: Record<string, unknown> };
127
+
128
+ type SyncDefinitionConfiguration<State> = SyncDatabaseConfiguration & {
129
+ primaryKey: string;
130
+ mode?: SyncMode;
131
+ schedule?: Schedule;
132
+ handler: (
133
+ state: State | undefined,
134
+ context: CapabilityContext,
135
+ ) => Promise<{ changes: RuntimeSyncChange[]; hasMore?: boolean; nextState?: State }>;
136
+ };
137
+
138
+ /** Resolve a Notion-as-Code data source to the Apps database used by its sync. */
139
+ function resolveSyncDatabase(configuration: SyncDatabaseConfiguration): Database {
140
+ return "dataSource" in configuration
141
+ ? configuration.dataSource.database
142
+ : configuration.database;
143
+ }
144
+
101
145
  export type SyncRunResult = {
102
146
  changes: unknown[];
103
147
  hasMore: boolean;
@@ -111,16 +155,30 @@ export type Sync = {
111
155
  handler: (context?: SyncRuntimeContext, options?: HandlerOptions) => Promise<SyncRunResult>;
112
156
  };
113
157
 
114
- /** Create a database sync. Default-export it from a file in `src/syncs/`. */
158
+ /** Create a database sync. Default-export it from a sync file. */
115
159
  export function createSync<
116
160
  const S extends DatabaseSchema,
117
161
  const PrimaryKey extends KeyPropertyName<S>,
118
162
  State = unknown,
119
163
  >(configuration: SyncConfiguration<S, PrimaryKey, State>): Sync {
164
+ return createSyncDefinition(configuration);
165
+ }
166
+
167
+ /** Create a sync backed by a Notion-as-Code data source. */
168
+ export function createDataSourceSync<
169
+ const Properties extends readonly NotionAsCodeProperty[],
170
+ const PrimaryKey extends KeyPropertyName<AppsSchemaForProperties<Properties>>,
171
+ State = unknown,
172
+ >(configuration: DataSourceSyncConfiguration<Properties, PrimaryKey, State>): Sync {
173
+ return createSyncDefinition(configuration);
174
+ }
175
+
176
+ function createSyncDefinition<State>(configuration: SyncDefinitionConfiguration<State>): Sync {
120
177
  const schedule =
121
178
  configuration.schedule === undefined ? undefined : parseSchedule(configuration.schedule);
179
+ const database = resolveSyncDatabase(configuration);
122
180
  const config: SyncDefinitionConfig = {
123
- database: configuration.database,
181
+ database,
124
182
  primaryKeyProperty: configuration.primaryKey,
125
183
  ...(configuration.mode === undefined ? {} : { mode: configuration.mode }),
126
184
  ...(schedule === undefined ? {} : { schedule }),
@@ -139,7 +197,7 @@ export function createSync<
139
197
  const handlerResult = await configuration.handler(state, createCapabilityContext());
140
198
  const changes = handlerResult.changes.map((change) => {
141
199
  if (change.type === "delete") {
142
- return { ...change, targetDatabaseKey: configuration.database.key };
200
+ return { ...change, targetDatabaseKey: database.key };
143
201
  }
144
202
  return {
145
203
  ...change,
@@ -147,7 +205,7 @@ export function createSync<
147
205
  ...change.properties,
148
206
  [configuration.primaryKey]: [[change.key]],
149
207
  },
150
- targetDatabaseKey: configuration.database.key,
208
+ targetDatabaseKey: database.key,
151
209
  };
152
210
  });
153
211
  const result: SyncRunResult = {