@notionhq/apps 0.0.17 → 0.0.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/AGENTS.md +27 -0
  2. package/README.md +77 -35
  3. package/dist/cli/build.d.ts.map +1 -1
  4. package/dist/cli/build.js +12 -7
  5. package/dist/cli/emit-manifest.d.ts +1 -1
  6. package/dist/cli/emit-manifest.d.ts.map +1 -1
  7. package/dist/cli/emit-manifest.js +66 -1
  8. package/dist/index.d.ts +2 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +3 -1
  11. package/dist/notion-as-code/custom-agent.d.ts +1 -0
  12. package/dist/notion-as-code/custom-agent.d.ts.map +1 -1
  13. package/dist/notion-as-code/custom-agent.js +1 -1
  14. package/dist/notion-as-code/database.d.ts +90 -48
  15. package/dist/notion-as-code/database.d.ts.map +1 -1
  16. package/dist/notion-as-code/database.js +135 -45
  17. package/dist/notion-as-code/database.test.d.ts +2 -0
  18. package/dist/notion-as-code/database.test.d.ts.map +1 -0
  19. package/dist/notion-as-code/index.d.ts +2 -2
  20. package/dist/notion-as-code/index.d.ts.map +1 -1
  21. package/dist/notion-as-code/intents.d.ts +34 -2
  22. package/dist/notion-as-code/intents.d.ts.map +1 -1
  23. package/dist/notion-as-code/page.d.ts +1 -0
  24. package/dist/notion-as-code/page.d.ts.map +1 -1
  25. package/dist/notion-as-code/page.js +1 -0
  26. package/dist/notion-as-code/schema.d.ts +8 -4
  27. package/dist/notion-as-code/schema.d.ts.map +1 -1
  28. package/dist/notion-as-code/views.d.ts +29 -10
  29. package/dist/notion-as-code/views.d.ts.map +1 -1
  30. package/dist/sync.d.ts +33 -15
  31. package/dist/sync.d.ts.map +1 -1
  32. package/dist/sync.js +12 -0
  33. package/dist/workflow-access-types.test.d.ts +2 -0
  34. package/dist/workflow-access-types.test.d.ts.map +1 -0
  35. package/dist/workflow-access.d.ts +68 -0
  36. package/dist/workflow-access.d.ts.map +1 -0
  37. package/dist/workflow-access.js +135 -0
  38. package/dist/workflow-state.d.ts +13 -0
  39. package/dist/workflow-state.d.ts.map +1 -0
  40. package/dist/workflow-state.js +107 -0
  41. package/dist/workflow.d.ts +65 -9
  42. package/dist/workflow.d.ts.map +1 -1
  43. package/dist/workflow.js +119 -5
  44. package/docs/BUILD.md +134 -4
  45. package/package.json +1 -1
  46. package/skills/notion-as-code/SKILL.md +89 -49
  47. package/skills/sync/SKILL.md +21 -18
  48. package/skills/workflow/SKILL.md +101 -17
  49. package/src/cli/build.test.ts +148 -64
  50. package/src/cli/build.ts +13 -8
  51. package/src/cli/emit-manifest.ts +107 -0
  52. package/src/index.ts +21 -1
  53. package/src/notion-as-code/custom-agent.ts +2 -1
  54. package/src/notion-as-code/database.test.ts +661 -0
  55. package/src/notion-as-code/database.ts +349 -127
  56. package/src/notion-as-code/index.ts +16 -1
  57. package/src/notion-as-code/intents.ts +39 -2
  58. package/src/notion-as-code/page.ts +2 -0
  59. package/src/notion-as-code/schema.ts +11 -3
  60. package/src/notion-as-code/views.ts +29 -10
  61. package/src/sync.test.ts +295 -0
  62. package/src/sync.ts +85 -21
  63. package/src/workflow-access-types.test.ts +69 -0
  64. package/src/workflow-access.ts +299 -0
  65. package/src/workflow-state.ts +152 -0
  66. package/src/workflow.test.ts +441 -1
  67. package/src/workflow.ts +230 -13
@@ -5,11 +5,15 @@ import type { SelectOption, StatusGroup } from "../types.js";
5
5
  import type {
6
6
  DataSourceDefinition,
7
7
  NotionAsCodeProperty,
8
+ NotionAsCodePropertyConfig,
9
+ NotionAsCodeSchema,
8
10
  NotionAsCodeSelectOption,
9
11
  NotionAsCodeStatusOption,
10
12
  } from "./intents.js";
11
13
 
12
- type AppsPropertyFor<Property extends NotionAsCodeProperty> = Property extends { type: "title" }
14
+ type AppsPropertyFor<Property extends NotionAsCodePropertyConfig> = Property extends {
15
+ type: "title";
16
+ }
13
17
  ? { type: "title" }
14
18
  : Property extends { type: "text" }
15
19
  ? { type: "text" }
@@ -35,8 +39,12 @@ type AppsPropertyFor<Property extends NotionAsCodeProperty> = Property extends {
35
39
  ? { type: "file" }
36
40
  : PropertyConfiguration;
37
41
 
38
- export type AppsSchemaForProperties<Properties extends readonly NotionAsCodeProperty[]> = {
39
- [Property in Properties[number] as Property["name"]]: AppsPropertyFor<Property>;
42
+ /**
43
+ * Map keyed authoring properties to the Apps schema used by sync handlers. The explicit
44
+ * string-key mapping avoids a homomorphic mapped type interfering with `NoInfer` key checks.
45
+ */
46
+ export type AppsSchemaForSchema<Schema extends NotionAsCodeSchema> = {
47
+ [Property in keyof Schema & string]: AppsPropertyFor<Schema[Property]>;
40
48
  };
41
49
 
42
50
  /** Convert a Notion-as-Code data source into the Apps database used by sync handlers. */
@@ -411,29 +411,48 @@ export type AdvancedFilterSchema = {
411
411
  * `dataSourceResourceId` must reference a data source created by the script or
412
412
  * supplied through existing resources.
413
413
  *
414
+ * Database declarations require at least one data source or at least one view.
415
+ * A linked-only database may omit `datasources`, or use `datasources: {}`, only
416
+ * when its `views` list is nonempty. `{}`, `{ datasources: {} }`, and
417
+ * `{ datasources: {}, views: [] }` are invalid.
418
+ *
419
+ * @example
420
+ * // A linked-only database can use a typed table view for an explicit source.
421
+ * const linkedTableView: TableViewSchema = {
422
+ * resourceId: "issues-table-view",
423
+ * type: "table",
424
+ * dataSourceResourceId: "issues-source",
425
+ * properties: [{ property: "issue-title", visible: true }]
426
+ * };
427
+ * const linkedDatabase = notion.database("issues-linked-database", {
428
+ * parent: { type: "resourceId", resourceId: "project-page" },
429
+ * views: [linkedTableView]
430
+ * })
431
+ *
414
432
  * @example
415
433
  * // When creating a database with a data source
416
- * const db = await notion.database({
417
- * resourceId: "my-database",
418
- * dataSources: [{ resourceId: "my-datasource", name: "Main", properties: [...] }]
434
+ * const db = notion.database("my-database", {
435
+ * dataSourceResourceId: "my-datasource",
436
+ * name: "Main",
437
+ * schema: { Name: { resourceId: "my-title", type: "title" } }
419
438
  * })
420
439
  *
421
440
  * // Views must reference that data source
422
- * await db.addView({
441
+ * db.addView({
423
442
  * resourceId: "my-table-view",
424
443
  * type: "table",
425
444
  * dataSourceResourceId: "my-datasource" // REQUIRED: matches data source above
426
445
  * })
427
446
  *
428
447
  * @example
429
- * // A linked database can reference another script-created or existing data source
430
- * const linkedDatabase = await notion.database({
431
- * resourceId: "linked-database",
432
- * parent: { type: "resourceId", resourceId: "project-page" },
448
+ * // The explicit empty `datasources` form is valid with a nonempty view list.
449
+ * const explicitLinkedDatabase = notion.database("issues-linked-database-explicit", {
450
+ * datasources: {},
433
451
  * views: [{
434
- * resourceId: "linked-table-view",
452
+ * resourceId: "issues-table-view-explicit",
435
453
  * type: "table",
436
- * dataSourceResourceId: "my-datasource" // Created elsewhere or supplied through an existing collection
454
+ * dataSourceResourceId: "issues-source",
455
+ * properties: [{ property: "issue-title", visible: true }]
437
456
  * }]
438
457
  * })
439
458
  */
package/src/sync.test.ts CHANGED
@@ -36,6 +36,20 @@ const keyTypes = createDatabase("key-types", {
36
36
  Project: Schema.relation(projects),
37
37
  },
38
38
  });
39
+ const partialMeetings = createDatabase("partial-meetings", {
40
+ schema: {
41
+ Name: Schema.title(),
42
+ "Meeting ID": Schema.richText(),
43
+ Other: Schema.richText(),
44
+ "Meeting date": Schema.date(),
45
+ "Prep complete": Schema.checkbox(),
46
+ },
47
+ });
48
+ const keyOnly = createDatabase("key-only", {
49
+ schema: {
50
+ ID: Schema.richText(),
51
+ },
52
+ });
39
53
 
40
54
  type IssueKey = KeyPropertyName<typeof issues.config.schema>;
41
55
  const textKey: IssueKey = "GitHub ID";
@@ -128,6 +142,188 @@ describe("createSync", () => {
128
142
  ).toBe("GitHub ID");
129
143
  });
130
144
 
145
+ it("accepts partial upsert properties in standalone handlers", () => {
146
+ const finiteProperties = {
147
+ Name: Builder.title("Prepared"),
148
+ Unknown: Builder.text("unexpected"),
149
+ };
150
+ const spreadProperties = {
151
+ Unknown: Builder.text("unexpected from spread"),
152
+ };
153
+
154
+ createSync({
155
+ database: partialMeetings,
156
+ primaryKey: "Meeting ID",
157
+ handler: async (state: { cursor: number } | undefined) => ({
158
+ changes: [
159
+ {
160
+ type: "upsert",
161
+ key: "meeting-1",
162
+ properties: { Name: Builder.title("One") },
163
+ },
164
+ {
165
+ type: "upsert",
166
+ key: "meeting-2",
167
+ properties: {
168
+ "Meeting date": Builder.date("2026-09-16"),
169
+ "Prep complete": Builder.checkbox(true),
170
+ },
171
+ },
172
+ { type: "delete", key: "meeting-3" },
173
+ ],
174
+ nextState: { cursor: (state?.cursor ?? 0) + 1 },
175
+ }),
176
+ });
177
+
178
+ const invalidSyncDeclarations = () => {
179
+ createSync({
180
+ database: partialMeetings,
181
+ primaryKey: "Meeting ID",
182
+ // @ts-expect-error Unknown property keys are rejected in inferred handler returns.
183
+ handler: async () => ({
184
+ changes: [
185
+ {
186
+ type: "upsert",
187
+ key: "invalid-inline",
188
+ properties: {
189
+ Name: Builder.title("Known"),
190
+ Unknown: Builder.text("unexpected"),
191
+ },
192
+ },
193
+ ],
194
+ }),
195
+ });
196
+ createSync({
197
+ database: partialMeetings,
198
+ primaryKey: "Meeting ID",
199
+ // @ts-expect-error Unknown properties remain rejected when another change shares the key.
200
+ handler: async () => ({
201
+ changes: [
202
+ {
203
+ type: "upsert",
204
+ key: "same",
205
+ properties: {
206
+ Name: Builder.title("Known"),
207
+ Unknown: Builder.text("unexpected"),
208
+ },
209
+ },
210
+ {
211
+ type: "upsert",
212
+ key: "same",
213
+ properties: { Name: Builder.title("Known sibling") },
214
+ },
215
+ ],
216
+ }),
217
+ });
218
+ createSync({
219
+ database: partialMeetings,
220
+ primaryKey: "Meeting ID",
221
+ // @ts-expect-error Upsert properties must include a non-primary-key schema property.
222
+ handler: async () => ({
223
+ changes: [
224
+ {
225
+ type: "upsert",
226
+ key: "invalid-empty",
227
+ properties: {},
228
+ },
229
+ ],
230
+ }),
231
+ });
232
+ createSync({
233
+ database: partialMeetings,
234
+ primaryKey: "Meeting ID",
235
+ // @ts-expect-error Finite property objects cannot contain unknown keys.
236
+ handler: async () => ({
237
+ changes: [
238
+ {
239
+ type: "upsert",
240
+ key: "invalid-finite",
241
+ properties: finiteProperties,
242
+ },
243
+ ],
244
+ }),
245
+ });
246
+ createSync({
247
+ database: partialMeetings,
248
+ primaryKey: "Meeting ID",
249
+ // @ts-expect-error Spread property objects cannot contain unknown keys.
250
+ handler: async () => ({
251
+ changes: [
252
+ {
253
+ type: "upsert",
254
+ key: "invalid-spread",
255
+ properties: {
256
+ Name: Builder.title("Known from spread"),
257
+ ...spreadProperties,
258
+ },
259
+ },
260
+ ],
261
+ }),
262
+ });
263
+ createSync({
264
+ database: partialMeetings,
265
+ primaryKey: "Meeting ID",
266
+ // @ts-expect-error Known properties must use their declared value types.
267
+ handler: async () => ({
268
+ changes: [
269
+ {
270
+ type: "upsert",
271
+ key: "invalid-value",
272
+ properties: {
273
+ Name: Builder.people("person@example.com"),
274
+ },
275
+ },
276
+ ],
277
+ }),
278
+ });
279
+ createSync({
280
+ database: partialMeetings,
281
+ primaryKey: "Meeting ID",
282
+ // @ts-expect-error The primary-key property is supplied from change.key.
283
+ handler: async () => ({
284
+ changes: [
285
+ {
286
+ type: "upsert",
287
+ key: "invalid-primary-property",
288
+ properties: {
289
+ "Meeting ID": Builder.text("explicit"),
290
+ },
291
+ },
292
+ ],
293
+ }),
294
+ });
295
+ createSync({
296
+ database: partialMeetings,
297
+ // @ts-expect-error Unknown schema properties cannot be sync primary keys.
298
+ primaryKey: "Unknown",
299
+ handler: async () => ({ changes: [] }),
300
+ });
301
+ };
302
+ void invalidSyncDeclarations;
303
+ });
304
+
305
+ it("rejects key-only upserts but allows deletes", () => {
306
+ const invalidKeyOnlyUpsert = () => {
307
+ createSync({
308
+ database: keyOnly,
309
+ primaryKey: "ID",
310
+ // @ts-expect-error Upserts require at least one non-primary-key schema property.
311
+ handler: async () => ({
312
+ changes: [{ type: "upsert", key: "invalid-key-only", properties: {} }],
313
+ }),
314
+ });
315
+ };
316
+ void invalidKeyOnlyUpsert;
317
+
318
+ createSync({
319
+ database: keyOnly,
320
+ primaryKey: "ID",
321
+ handler: async () => ({
322
+ changes: [{ type: "delete", key: "deleted-key-only" }],
323
+ }),
324
+ });
325
+ });
326
+
131
327
  it("rejects invalid schedules when the sync is created", () => {
132
328
  expect(() =>
133
329
  createSync({
@@ -177,6 +373,105 @@ describe("createSync", () => {
177
373
  ]);
178
374
  });
179
375
 
376
+ it("preserves omitted properties, partial upserts, and deletes at runtime", async () => {
377
+ const sync = createSync({
378
+ database: partialMeetings,
379
+ primaryKey: "Meeting ID",
380
+ handler: async (state: { cursor: number } | undefined) => ({
381
+ changes: [
382
+ {
383
+ type: "upsert",
384
+ key: "meeting-1",
385
+ properties: { Name: Builder.title("One") },
386
+ },
387
+ {
388
+ type: "upsert",
389
+ key: "meeting-2",
390
+ properties: { Other: Builder.text("Only other") },
391
+ },
392
+ {
393
+ type: "upsert",
394
+ key: "meeting-3",
395
+ properties: {
396
+ "Meeting date": Builder.date("2026-09-16"),
397
+ "Prep complete": Builder.checkbox(true),
398
+ },
399
+ },
400
+ { type: "delete", key: "meeting-4" },
401
+ ],
402
+ nextState: { cursor: (state?.cursor ?? 0) + 1 },
403
+ }),
404
+ });
405
+
406
+ const result = await sync.handler({ state: { cursor: 4 } }, { concreteOutput: true });
407
+
408
+ expect(result).toEqual({
409
+ changes: [
410
+ {
411
+ type: "upsert",
412
+ key: "meeting-1",
413
+ properties: {
414
+ Name: [["One"]],
415
+ "Meeting ID": [["meeting-1"]],
416
+ },
417
+ targetDatabaseKey: "partial-meetings",
418
+ },
419
+ {
420
+ type: "upsert",
421
+ key: "meeting-2",
422
+ properties: {
423
+ Other: [["Only other"]],
424
+ "Meeting ID": [["meeting-2"]],
425
+ },
426
+ targetDatabaseKey: "partial-meetings",
427
+ },
428
+ {
429
+ type: "upsert",
430
+ key: "meeting-3",
431
+ properties: {
432
+ "Meeting date": [
433
+ ["‣", [["d", { type: "date", start_date: "2026-09-16" }]]],
434
+ ],
435
+ "Prep complete": [["Yes"]],
436
+ "Meeting ID": [["meeting-3"]],
437
+ },
438
+ targetDatabaseKey: "partial-meetings",
439
+ },
440
+ {
441
+ type: "delete",
442
+ key: "meeting-4",
443
+ targetDatabaseKey: "partial-meetings",
444
+ },
445
+ ],
446
+ hasMore: false,
447
+ nextUserContext: { cursor: 5 },
448
+ nextPacerStates: {},
449
+ });
450
+ });
451
+
452
+ it("rejects empty upserts at runtime", async () => {
453
+ const sync = createSync({
454
+ database: partialMeetings,
455
+ primaryKey: "Meeting ID",
456
+ handler: async () => ({
457
+ changes: [
458
+ {
459
+ type: "upsert",
460
+ key: "meeting-empty",
461
+ properties: {},
462
+ icon: Builder.notionIcon("warning"),
463
+ cover: Builder.imageCover("https://example.com/empty-cover.png"),
464
+ pageContentMarkdown: "# Empty properties",
465
+ },
466
+ ],
467
+ }),
468
+ } as never);
469
+
470
+ await expect(sync.handler(undefined, { concreteOutput: true })).rejects.toBeInstanceOf(
471
+ ExecutionError,
472
+ );
473
+ });
474
+
180
475
  it("passes deletes, paging state, and incremental mode through", async () => {
181
476
  const sync = createSync({
182
477
  database: issues,
package/src/sync.ts CHANGED
@@ -3,8 +3,8 @@ import { createCapabilityContext } from "./context.js";
3
3
  import type { Database } from "./database.js";
4
4
  import { ExecutionError, RateLimitError } from "./error.js";
5
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";
6
+ import type { NotionAsCodeSchema } from "./notion-as-code/intents.js";
7
+ import type { AppsSchemaForSchema } from "./notion-as-code/schema.js";
8
8
  import { writeOutput } from "./output.js";
9
9
  import { initPacerState, pacerStates, type PacerEntry } from "./pacer.js";
10
10
  import { resolveRuntimeInput } from "./runtime-input.js";
@@ -45,12 +45,50 @@ type PropertyValue<T extends PropertyConfiguration> = T extends { type: "people"
45
45
  ? RelationValue
46
46
  : TextValue;
47
47
 
48
+ type SyncPropertyName<S extends DatabaseSchema> = Extract<keyof S, string>;
49
+
50
+ /**
51
+ * Collect unknown property names across the whole inferred change union before checking it.
52
+ * Otherwise a valid subset properties object could structurally hide an invalid sibling row.
53
+ */
54
+ type SyncUnknownPropertyNames<AllowedPropertyNames extends string, Change> = Change extends {
55
+ type: "upsert";
56
+ properties: infer Properties extends object;
57
+ }
58
+ ? Exclude<keyof Properties, AllowedPropertyNames>
59
+ : never;
60
+
61
+ /** Require at least one key while keeping all other keys optional. */
62
+ type AtLeastOne<T extends object> = Partial<T> &
63
+ {
64
+ [RequiredKey in keyof T]-?: Required<Pick<T, RequiredKey>>;
65
+ }[keyof T];
66
+
67
+ type SyncUpsertProperties<
68
+ S extends DatabaseSchema,
69
+ PrimaryKey extends KeyPropertyName<S>,
70
+ > = AtLeastOne<{
71
+ [P in Exclude<SyncPropertyName<S>, PrimaryKey>]: PropertyValue<S[P]>;
72
+ }>;
73
+
74
+ type ExactSyncHandlerResult<
75
+ AllowedPropertyNames extends string,
76
+ Result extends { changes: readonly unknown[] },
77
+ > = Result & {
78
+ changes: [SyncUnknownPropertyNames<AllowedPropertyNames, Result["changes"][number]>] extends [
79
+ never,
80
+ ]
81
+ ? Result["changes"]
82
+ : never;
83
+ };
84
+
48
85
  export type SyncMode = "replace" | "incremental";
49
86
 
87
+ /** Upsert one or more known non-primary-key properties; the key is supplied separately. */
50
88
  export type SyncChangeUpsert<S extends DatabaseSchema, PrimaryKey extends KeyPropertyName<S>> = {
51
89
  type: "upsert";
52
90
  key: string;
53
- properties: Omit<{ [P in keyof S]: PropertyValue<S[P]> }, PrimaryKey>;
91
+ properties: SyncUpsertProperties<S, PrimaryKey>;
54
92
  upstreamUpdatedAt?: string;
55
93
  icon?: Icon;
56
94
  cover?: Cover;
@@ -77,6 +115,11 @@ export type SyncConfiguration<
77
115
  S extends DatabaseSchema,
78
116
  PrimaryKey extends KeyPropertyName<S>,
79
117
  State,
118
+ Result extends SyncHandlerResult<S, PrimaryKey, State> = SyncHandlerResult<
119
+ S,
120
+ PrimaryKey,
121
+ State
122
+ >,
80
123
  > = {
81
124
  database: Database<S>;
82
125
  primaryKey: PrimaryKey;
@@ -85,25 +128,25 @@ export type SyncConfiguration<
85
128
  handler: (
86
129
  state: State | undefined,
87
130
  context: CapabilityContext,
88
- ) => Promise<SyncHandlerResult<NoInfer<S>, NoInfer<PrimaryKey>, State>>;
131
+ ) => Promise<
132
+ // NoInfer prevents handler output from widening the source schema or primary-key inference.
133
+ SyncHandlerResult<NoInfer<S>, NoInfer<PrimaryKey>, State> &
134
+ ExactSyncHandlerResult<
135
+ Exclude<SyncPropertyName<NoInfer<S>>, NoInfer<PrimaryKey>>,
136
+ Result
137
+ >
138
+ >;
89
139
  };
90
140
 
91
141
  /** Configuration for a sync backed by a Notion-as-Code data source. */
92
142
  export type DataSourceSyncConfiguration<
93
- Properties extends readonly NotionAsCodeProperty[],
94
- PrimaryKey extends KeyPropertyName<AppsSchemaForProperties<Properties>>,
143
+ Schema extends NotionAsCodeSchema,
144
+ PrimaryKey extends KeyPropertyName<AppsSchemaForSchema<Schema>>,
95
145
  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
- >;
146
+ Result extends SyncHandlerResult<AppsSchemaForSchema<Schema>, PrimaryKey, State> =
147
+ SyncHandlerResult<AppsSchemaForSchema<Schema>, PrimaryKey, State>,
148
+ > = Omit<SyncConfiguration<AppsSchemaForSchema<Schema>, PrimaryKey, State, Result>, "database"> & {
149
+ dataSource: DataSourceHandle<Schema>;
107
150
  };
108
151
 
109
152
  export type SyncRuntimeContext = {
@@ -160,16 +203,23 @@ export function createSync<
160
203
  const S extends DatabaseSchema,
161
204
  const PrimaryKey extends KeyPropertyName<S>,
162
205
  State = unknown,
163
- >(configuration: SyncConfiguration<S, PrimaryKey, State>): Sync {
206
+ const Result extends SyncHandlerResult<S, PrimaryKey, State> = SyncHandlerResult<
207
+ S,
208
+ PrimaryKey,
209
+ State
210
+ >,
211
+ >(configuration: SyncConfiguration<S, PrimaryKey, State, Result>): Sync {
164
212
  return createSyncDefinition(configuration);
165
213
  }
166
214
 
167
215
  /** Create a sync backed by a Notion-as-Code data source. */
168
216
  export function sync<
169
- const Properties extends readonly NotionAsCodeProperty[],
170
- const PrimaryKey extends KeyPropertyName<AppsSchemaForProperties<Properties>>,
217
+ const Schema extends NotionAsCodeSchema,
218
+ const PrimaryKey extends KeyPropertyName<AppsSchemaForSchema<Schema>>,
171
219
  State = unknown,
172
- >(configuration: DataSourceSyncConfiguration<Properties, PrimaryKey, State>): Sync {
220
+ const Result extends SyncHandlerResult<AppsSchemaForSchema<Schema>, PrimaryKey, State> =
221
+ SyncHandlerResult<AppsSchemaForSchema<Schema>, PrimaryKey, State>,
222
+ >(configuration: DataSourceSyncConfiguration<Schema, PrimaryKey, State, Result>): Sync {
173
223
  return createSyncDefinition(configuration);
174
224
  }
175
225
 
@@ -199,6 +249,20 @@ function createSyncDefinition<State>(configuration: SyncDefinitionConfiguration<
199
249
  if (change.type === "delete") {
200
250
  return { ...change, targetDatabaseKey: database.key };
201
251
  }
252
+
253
+ let hasOwnEnumerableProperty = false;
254
+ for (const propertyName in change.properties) {
255
+ if (Object.hasOwn(change.properties, propertyName)) {
256
+ hasOwnEnumerableProperty = true;
257
+ break;
258
+ }
259
+ }
260
+ if (!hasOwnEnumerableProperty) {
261
+ throw new Error(
262
+ "Sync upsert properties must include at least one property.",
263
+ );
264
+ }
265
+
202
266
  return {
203
267
  ...change,
204
268
  properties: {
@@ -0,0 +1,69 @@
1
+ import { expectTypeOf, it } from "vitest";
2
+
3
+ import { customAgent } from "./notion-as-code/custom-agent.js";
4
+ import { database } from "./notion-as-code/database.js";
5
+ import { page } from "./notion-as-code/page.js";
6
+ import { triggers } from "./triggers.generated.js";
7
+ import { workflow } from "./workflow.js";
8
+
9
+ it("infers access aliases and rejects resource-specific levels", () => {
10
+ const home = page({ resourceId: "home" });
11
+ const agent = customAgent({ resourceId: "agent", name: "Agent" });
12
+ const singleDatabase = database("single-database", {
13
+ dataSourceResourceId: "single-source",
14
+ name: "Single",
15
+ schema: { Title: { resourceId: "single-title", type: "title" } },
16
+ });
17
+ const multiDatabase = database("multi-database", {
18
+ name: "Multi",
19
+ datasources: {
20
+ Records: {
21
+ resourceId: "multi-source",
22
+ schema: { Title: { resourceId: "multi-title", type: "title" } },
23
+ },
24
+ },
25
+ });
26
+ workflow({
27
+ name: "Access types",
28
+ description: "Checks access types",
29
+ triggers: [triggers.notionPageCreated()],
30
+ access: {
31
+ home: { resource: home, level: "view" },
32
+ singleDatabase: { resource: singleDatabase, level: "edit" },
33
+ multiDatabase: { resource: multiDatabase, level: "view" },
34
+ },
35
+ handler: (_event, context) => {
36
+ expectTypeOf(context.access.home.type).toEqualTypeOf<"page">();
37
+ expectTypeOf(context.access.home.id).toEqualTypeOf<string>();
38
+ expectTypeOf(context.access.singleDatabase.type).toEqualTypeOf<"database">();
39
+ expectTypeOf(context.access.multiDatabase.type).toEqualTypeOf<"database">();
40
+ // @ts-expect-error Access aliases are limited to declarations.
41
+ void context.access.missing;
42
+ // @ts-expect-error Access values are readonly.
43
+ context.access.home.id = "changed";
44
+ // @ts-expect-error The access map is readonly.
45
+ context.access = {};
46
+ },
47
+ });
48
+
49
+ const invalidAccessLevels = () => {
50
+ workflow({
51
+ name: "Agent access types",
52
+ description: "Checks custom agent access types",
53
+ triggers: [triggers.notionPageCreated()],
54
+ // @ts-expect-error Custom agents only support the call level.
55
+ access: { agent: { resource: agent, level: "view" } },
56
+ handler: () => {},
57
+ });
58
+
59
+ workflow({
60
+ name: "Page access types",
61
+ description: "Checks page access types",
62
+ triggers: [triggers.notionPageCreated()],
63
+ // @ts-expect-error Pages do not support the custom-agent call level.
64
+ access: { home: { resource: home, level: "call" } },
65
+ handler: () => {},
66
+ });
67
+ };
68
+ void invalidAccessLevels;
69
+ });