@vercel/board 0.28.0 → 0.30.0

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 (139) hide show
  1. package/dist/{a2-B0_VlbtC.mjs → a2-D_28Pdip.mjs} +197 -259
  2. package/dist/access-CQ39gqEV.mjs +226 -0
  3. package/dist/access.d.mts +1 -1
  4. package/dist/access.mjs +3 -3
  5. package/dist/{actors-BLXVp4oZ.mjs → actors-xPiamLWk.mjs} +3 -3
  6. package/dist/{auth-lTkOgvcE.mjs → auth-BPinwfN0.mjs} +3 -226
  7. package/dist/backend-a2/blob-vercel.d.mts +1 -1
  8. package/dist/backend-a2/contract.d.mts +2 -2
  9. package/dist/backend-a2/contract.mjs +3 -3
  10. package/dist/backend-a2/scheduler-local.d.mts +1 -1
  11. package/dist/backend-a2/scheduler-local.mjs +1 -1
  12. package/dist/backend-a2/scheduler-qstash.d.mts +3 -3
  13. package/dist/backend-a2/scheduler-qstash.mjs +8 -8
  14. package/dist/backend-a2/scheduler-vercel.d.mts +3 -3
  15. package/dist/backend-a2/scheduler-vercel.mjs +6 -6
  16. package/dist/backend-a2/store-memory.d.mts +2 -2
  17. package/dist/backend-a2/store-memory.mjs +3 -3
  18. package/dist/backend-a2/store-postgres.d.mts +1 -1
  19. package/dist/backend-a2/store-postgres.mjs +34 -15
  20. package/dist/backend-a2/store-redis-http.d.mts +1 -1
  21. package/dist/backend-a2/store-redis-http.mjs +6 -6
  22. package/dist/backend-a2/store-redis.d.mts +2 -2
  23. package/dist/backend-a2/store-redis.mjs +5 -5
  24. package/dist/backend-a2/store-sqlite.d.mts +1 -1
  25. package/dist/backend-a2/store-sqlite.mjs +3 -3
  26. package/dist/backend-a2.d.mts +20 -7
  27. package/dist/backend-a2.mjs +2 -1
  28. package/dist/{backend-http-BvNFrvcY.mjs → backend-http-16SbvnAK.mjs} +2 -2
  29. package/dist/{backend-http-E7YU0qhn.d.mts → backend-http-3PoBYQoo.d.mts} +15 -3
  30. package/dist/{backend-http-CfLVYqmj.mjs → backend-http-BPaNYLWj.mjs} +4 -5
  31. package/dist/backend-http.d.mts +1 -1
  32. package/dist/backend-http.mjs +1 -1
  33. package/dist/backend-http.node.d.mts +1 -1
  34. package/dist/backend-http.node.mjs +1 -1
  35. package/dist/{backend-ZKYTe3xa.d.mts → backend-n3hajfrD.d.mts} +12 -6
  36. package/dist/{backend-vercel-D87pIp2k.mjs → backend-vercel-C4Kee2-K.mjs} +260 -30
  37. package/dist/{backend-vercel-DTrDiSk5.mjs → backend-vercel-Kd8nvW5A.mjs} +3 -3
  38. package/dist/{backend-vercel-CIRObA0t.d.mts → backend-vercel-qznuDxnN.d.mts} +20 -5
  39. package/dist/backend-vercel.d.mts +1 -1
  40. package/dist/backend-vercel.mjs +1 -1
  41. package/dist/backend-vercel.node.d.mts +1 -1
  42. package/dist/backend-vercel.node.mjs +1 -1
  43. package/dist/{blob-B-9AvYis.d.mts → blob-CqILvdmr.d.mts} +2 -2
  44. package/dist/blob-DQK5prXT.mjs +182 -0
  45. package/dist/{chat-provider-DnHibqnM.mjs → chat-provider-COtsU5jB.mjs} +2 -2
  46. package/dist/{chat-provider-DsNEBQiA.d.mts → chat-provider-D6jVT4wS.d.mts} +5 -3
  47. package/dist/cli-commands.d.mts +1 -1
  48. package/dist/cli-commands.mjs +1 -1
  49. package/dist/cli-embedded.d.mts +2 -2
  50. package/dist/cli-embedded.mjs +1 -1
  51. package/dist/cli.mjs +5 -5
  52. package/dist/{client-xLxeDz0f.d.mts → client-CjAatpV4.d.mts} +67 -10
  53. package/dist/{commands-BFookFtn.mjs → commands-B5DHjZKK.mjs} +54 -10
  54. package/dist/{commands-D6gvrOh3.d.mts → commands-BtH69yqt.d.mts} +17 -0
  55. package/dist/{conformance-DlnfuzEB.mjs → conformance-C7fwIvmN.mjs} +258 -18
  56. package/dist/conformance.d.mts +1 -1
  57. package/dist/conformance.mjs +1 -1
  58. package/dist/{engine-Bfe6pcXb.mjs → engine-DkdkXQEy.mjs} +21 -13
  59. package/dist/{errors-CB4tnZGz.d.mts → errors-87TVtXeb.d.mts} +1 -1
  60. package/dist/{client-D0Px7KzF.mjs → errors-C_5rB8Iz.mjs} +104 -4
  61. package/dist/{events-CewD4kw7.mjs → events-qf5XG5VQ.mjs} +2 -2
  62. package/dist/{exports-CCzw2Vdw.mjs → exports-CG0P8Qi4.mjs} +59 -33
  63. package/dist/exports-CrGA5_yN.d.mts +5 -0
  64. package/dist/{external-BV_cUncs.mjs → external-D3p5T57R.mjs} +4 -2
  65. package/dist/{folds-jS9wlh9_.d.mts → folds-8wRRXjAt.d.mts} +4 -0
  66. package/dist/{folds-Dyjput8M.mjs → folds-DGYkMW19.mjs} +3 -2
  67. package/dist/{index-C05it0mC.d.mts → index-BygAXP7t.d.mts} +2 -2
  68. package/dist/index.d.mts +6 -6
  69. package/dist/{index.default-DmKxH_E6.mjs → index.default-DhkgpfR2.mjs} +1 -1
  70. package/dist/index.mjs +5 -6
  71. package/dist/index.node.d.mts +6 -6
  72. package/dist/index.node.mjs +3 -4
  73. package/dist/inspect/app.js +29 -29
  74. package/dist/inspect/engine.js +5 -5
  75. package/dist/{internal-CvsrprnU-CRY89I-b.mjs → internal-CvsrprnU-zZMpDiRY.mjs} +1 -1
  76. package/dist/{local-DhkrAqPP.mjs → local-C76Fy5ad.mjs} +1 -1
  77. package/dist/{main-eci7w-aP.mjs → main-D2s-QBOK.mjs} +76 -26
  78. package/dist/{middleware-ChKM1zuu.mjs → middleware-DlJQYiwV.mjs} +2 -2
  79. package/dist/{node-Zd_C-gxP.mjs → node-D8Rtg1su.mjs} +1 -1
  80. package/dist/{platform-B4TnJtWu-CwcTyKg2.mjs → platform-B4TnJtWu-Do3agKr5.mjs} +1 -1
  81. package/dist/{project-DMDST9kl.mjs → project-C_2xs57L.mjs} +1 -1
  82. package/dist/{provider-poIkXapd.mjs → provider-BaVmi4Hi.mjs} +3 -1
  83. package/dist/{provider-Cdg1qadh.d.mts → provider-Bm46LAdp.d.mts} +9 -2
  84. package/dist/{redis-tables-DN9PfIDi.mjs → redis-tables-BWJZHUGg.mjs} +41 -25
  85. package/dist/{reducer-CjmvZSAb-D_OSISmj.mjs → reducer-Bswr11ar-P324o9vn.mjs} +2 -2
  86. package/dist/{reducer-CYPHsSmd-BQIfozGi.d.mts → reducer-CYPHsSmd-BXjGiptm.d.mts} +1 -1
  87. package/dist/{reducers-BcmSs4qf.mjs → reducers-8jqLewgw.mjs} +5 -5
  88. package/dist/{retryable-lazy-DZWmHpii-Bye-W8q7.mjs → retryable-lazy-DZWmHpii-DpTc5PIP.mjs} +1 -1
  89. package/dist/{scheduler-task-X1SToDjP-BVoWuInI.mjs → scheduler-task-X1SToDjP-D2Il2rln.mjs} +1 -1
  90. package/dist/{schemas-Be08nyfW.mjs → schemas-DBmUO6pr.mjs} +2 -1
  91. package/dist/{serve-B09an9DQ.mjs → serve-lPoBDEds.mjs} +7 -6
  92. package/dist/{server-qgez0Xny-D5mLfqFQ.d.mts → server-B9gu0qi1-DI1WvcVK.d.mts} +10 -4
  93. package/dist/{server-DTX0prGr.mjs → server-D82a4uMe.mjs} +4 -4
  94. package/dist/{server-DtFrWxi3-Boa6gdWO.mjs → server-DX7mM6nm-D7N_7bug.mjs} +32 -23
  95. package/dist/{serving-CvOWEym2.mjs → serving-DKny38LK.mjs} +1 -1
  96. package/dist/{session-clear-wHd5GCpZ-BnKSxReK.mjs → session-clear-XJYpmUpg-2q8l_FJR.mjs} +14 -2
  97. package/dist/{sql-U9WTxrwJ.mjs → sql-Bj0fk6dn.mjs} +2 -2
  98. package/dist/{sqlite-db-Ds4majtT.mjs → sqlite-db-Dc8G78XV.mjs} +1 -1
  99. package/dist/{store-D0MhBvg6-4Z2L0Kqd.d.mts → store-DEzcHZOA-B-5vhxef.d.mts} +12 -2
  100. package/dist/store-N8PXxDAS-DWWgv-Vz.mjs +5 -0
  101. package/dist/{store-codec-DwrWlgQx-CYxGW0aD.mjs → store-codec-pAG2oPgQ-SqDXlPBR.mjs} +4 -4
  102. package/dist/store-memory-4IBl0Ztu.mjs +2 -0
  103. package/dist/{store-memory-Bas9Uq5C.mjs → store-memory-B0PkVSuQ.mjs} +14 -7
  104. package/dist/{store-polling-BZt2Rf3D-BiEM6_HB.mjs → store-polling-BZt2Rf3D-Div-bJ_D.mjs} +2 -2
  105. package/dist/{store-presence-polling-JuAy2Ck2-CYpuK75X.mjs → store-presence-polling-JuAy2Ck2-Dh-g_KYM.mjs} +3 -3
  106. package/dist/{store-sqlite-CxxPCcCT.mjs → store-sqlite-D85t3tE7.mjs} +14 -7
  107. package/dist/store-sqlite-iBTYYKBj.mjs +2 -0
  108. package/dist/{stream-buffer-DrTyiIuo-BKy-vI14.mjs → stream-buffer-DrTyiIuo-Be10yOBR.mjs} +2 -2
  109. package/dist/sync-github.d.mts +4 -2
  110. package/dist/sync-github.mjs +4 -14
  111. package/dist/sync-kapso.d.mts +3 -2
  112. package/dist/sync-kapso.mjs +3 -3
  113. package/dist/sync-linear.d.mts +12 -2
  114. package/dist/sync-linear.mjs +191 -9
  115. package/dist/sync-slack.d.mts +41 -5
  116. package/dist/sync-slack.mjs +70 -11
  117. package/dist/sync.chat.d.mts +1 -1
  118. package/dist/sync.chat.mjs +1 -1
  119. package/dist/sync.d.mts +2 -2
  120. package/dist/sync.mjs +3 -3
  121. package/dist/tags-BpFo6q0_.mjs +14 -0
  122. package/dist/{targets-C-wdR5wP.mjs → targets-Cgjs29T0.mjs} +1 -1
  123. package/dist/{tokens-BuCU-61R.mjs → tokens-C5_yGRlL.mjs} +2 -2
  124. package/dist/{types-aCAf6lGU.d.mts → types-shZmGZ-q.d.mts} +2 -2
  125. package/dist/{watchers-hash-7DJU9b-n.mjs → watchers-hash-B_5b0-ZG.mjs} +50 -19
  126. package/docs/01-board/04-messages.md +93 -0
  127. package/docs/01-board/08-middleware.md +5 -2
  128. package/docs/02-sync/03-slack.md +6 -2
  129. package/docs/02-sync/07-linear.md +28 -6
  130. package/docs/04-reference/01-cli.md +27 -12
  131. package/docs/04-reference/02-board.md +20 -5
  132. package/docs/04-reference/03-http.md +17 -6
  133. package/docs/04-reference/05-conformance.md +14 -3
  134. package/package.json +2 -2
  135. package/dist/errors-CusN4x2l.mjs +0 -88
  136. package/dist/exports-D22M68rk.d.mts +0 -5
  137. package/dist/store-N8PXxDAS-BWBgzBHp.mjs +0 -5
  138. package/dist/store-memory-ZLiZbH3R.mjs +0 -2
  139. package/dist/store-sqlite-BmwL3pUR.mjs +0 -2
@@ -120,6 +120,23 @@ const options = {
120
120
  value: "k",
121
121
  description: "remove a label"
122
122
  },
123
+ if: {
124
+ type: "string",
125
+ multiple: true,
126
+ value: "k=v",
127
+ description: "only while the message has this label now"
128
+ },
129
+ "if-unset": {
130
+ type: "string",
131
+ multiple: true,
132
+ value: "k",
133
+ description: "only while the message doesn't have this label"
134
+ },
135
+ "if-revision": {
136
+ type: "string",
137
+ value: "N",
138
+ description: "only if it is still at this revision, as read --json shows it"
139
+ },
123
140
  channel: {
124
141
  type: "string",
125
142
  value: "c",
@@ -243,6 +260,8 @@ const text = (description = "what it says: - reads it from stdin, and it may be
243
260
  required: false,
244
261
  description
245
262
  });
263
+ /** What `--after` means on a reply: a precondition, where on a read it's a position. */
264
+ const AFTER = "a reply only while this is the thread's newest message";
246
265
  /** What one of the CLI's help pages lists together, in this order. */
247
266
  const groups = [
248
267
  "you",
@@ -369,7 +388,8 @@ const commands = [
369
388
  option("attach"),
370
389
  option("sync"),
371
390
  option("no-sync"),
372
- option("idempotency-key")
391
+ option("idempotency-key"),
392
+ option("after", AFTER)
373
393
  ],
374
394
  examples: [{
375
395
  name: "start a thread",
@@ -387,11 +407,15 @@ const commands = [
387
407
  options: [
388
408
  option("attach"),
389
409
  option("idempotency-key"),
390
- option("no-sync")
410
+ option("no-sync"),
411
+ option("after", AFTER)
391
412
  ],
392
413
  examples: [{
393
414
  name: "reply in a thread",
394
415
  value: "reply 812 \"on it\""
416
+ }, {
417
+ name: "reply only if #815 is still the thread's newest",
418
+ value: "reply 812 \"on it\" --after 815"
395
419
  }]
396
420
  },
397
421
  {
@@ -465,10 +489,13 @@ const commands = [
465
489
  required: true,
466
490
  description: "its new text: - reads it from stdin"
467
491
  }],
468
- options: [],
492
+ options: [option("if-revision")],
469
493
  examples: [{
470
494
  name: "fix a typo",
471
495
  value: "edit 812 \"deploys are green again\""
496
+ }, {
497
+ name: "only if no one changed it since you read it",
498
+ value: "edit 812 \"step 3 of 5\" --if-revision 820"
472
499
  }]
473
500
  },
474
501
  {
@@ -477,7 +504,7 @@ const commands = [
477
504
  aliases: ["rm", "del"],
478
505
  description: "delete a message, leaving a tombstone",
479
506
  arguments: [id()],
480
- options: [],
507
+ options: [option("if-revision")],
481
508
  examples: [{
482
509
  name: "delete a message",
483
510
  value: "delete 812"
@@ -508,11 +535,25 @@ const commands = [
508
535
  multiple: true,
509
536
  description: "a label to set"
510
537
  }],
511
- options: [option("unset")],
512
- examples: [{
513
- name: "mark a bug fixed",
514
- value: "label 812 state=fixed --unset wip"
515
- }]
538
+ options: [
539
+ option("unset"),
540
+ option("if"),
541
+ option("if-unset")
542
+ ],
543
+ examples: [
544
+ {
545
+ name: "mark a bug fixed",
546
+ value: "label 812 state=fixed --unset wip"
547
+ },
548
+ {
549
+ name: "claim a thread, unless someone has",
550
+ value: "label 812 owner=claude --if-unset owner"
551
+ },
552
+ {
553
+ name: "move it on from open only",
554
+ value: "label 812 state=fixing --if state=open"
555
+ }
556
+ ]
516
557
  },
517
558
  {
518
559
  group: "messages",
@@ -590,10 +631,13 @@ const commands = [
590
631
  required: true,
591
632
  description: "a channel ('#general'), a thread, or a message link"
592
633
  }],
593
- options: [option("limit", "messages to read")],
634
+ options: [option("limit", "messages to read"), option("after", "where the last read left off: the cursor it printed")],
594
635
  examples: [{
595
636
  name: "read a Slack channel",
596
637
  value: "sources slack read '#general'"
638
+ }, {
639
+ name: "read a Linear project's issues",
640
+ value: "sources linear read https://linear.app/acme/project/checkout-a1b2c3"
597
641
  }]
598
642
  }, {
599
643
  name: "join",
@@ -166,6 +166,23 @@ declare const options: {
166
166
  readonly value: 'k';
167
167
  readonly description: 'remove a label';
168
168
  };
169
+ readonly if: {
170
+ readonly type: 'string';
171
+ readonly multiple: true;
172
+ readonly value: 'k=v';
173
+ readonly description: 'only while the message has this label now';
174
+ };
175
+ readonly 'if-unset': {
176
+ readonly type: 'string';
177
+ readonly multiple: true;
178
+ readonly value: 'k';
179
+ readonly description: "only while the message doesn't have this label";
180
+ };
181
+ readonly 'if-revision': {
182
+ readonly type: 'string';
183
+ readonly value: 'N';
184
+ readonly description: 'only if it is still at this revision, as read --json shows it';
185
+ };
169
186
  readonly channel: {
170
187
  readonly type: 'string';
171
188
  readonly value: 'c';
@@ -1,4 +1,5 @@
1
- import { t as BoardError } from "./client-D0Px7KzF.mjs";
1
+ import { u as BoardError } from "./errors-C_5rB8Iz.mjs";
2
+ import { r as compileAccess } from "./access-CQ39gqEV.mjs";
2
3
  //#region src/conformance/index.ts
3
4
  var Mismatch = class extends Error {};
4
5
  const show = (value) => JSON.stringify(value) ?? String(value);
@@ -41,6 +42,31 @@ const needRoot = (ctx) => {
41
42
  reply: ctx.reply
42
43
  };
43
44
  };
45
+ /** The sync key the sync checks register, a platform of their own, so nothing reaches a real one. */
46
+ const SYNC_KEY = "conformance";
47
+ /**
48
+ * Registers a sync on `SYNC_KEY` for a check, and says where to put the token's registration back
49
+ * after: or why it can't, since registering replaces the token's watchers.
50
+ */
51
+ async function registerSync(ctx) {
52
+ const registered = (await ctx.board.watchers()).find((registration) => registration.owner === ctx.me.principal);
53
+ if (registered && (Object.keys(registered.watch).length > 0 || (registered.sync?.length ?? 0) > 0)) return { skip: "this token has watchers or a sync registered here, which registering would replace" };
54
+ const where = wokenAt(registered);
55
+ try {
56
+ await ctx.board.register({
57
+ url: null,
58
+ watch: {},
59
+ sync: [{
60
+ key: SYNC_KEY,
61
+ platform: SYNC_KEY
62
+ }]
63
+ });
64
+ } catch (error) {
65
+ if (error instanceof BoardError && error.status === 403) return { skip: "this token can't register a sync here" };
66
+ throw error;
67
+ }
68
+ return { where };
69
+ }
44
70
  async function post(ctx, text, extra = {}) {
45
71
  const message = await ctx.board.post(ctx.channel, {
46
72
  text,
@@ -51,6 +77,15 @@ async function post(ctx, text, extra = {}) {
51
77
  ctx.posted.push(message.id);
52
78
  return message;
53
79
  }
80
+ async function reply(ctx, re, text, extra = {}) {
81
+ const message = await ctx.board.reply(re, {
82
+ text,
83
+ noSync: true,
84
+ ...extra
85
+ });
86
+ ctx.posted.push(message.id);
87
+ return message;
88
+ }
54
89
  const CHECKS = [
55
90
  {
56
91
  group: "core",
@@ -80,6 +115,31 @@ const CHECKS = [
80
115
  check((await ctx.board.thread(root.id)).replies.some((m) => m.id === reply.id), `the thread's replies should include ${reply.id}`);
81
116
  }
82
117
  },
118
+ {
119
+ group: "core",
120
+ name: "replies after a message only while it's the thread's newest, one of any that race",
121
+ run: async (ctx) => {
122
+ const root = await post(ctx, "conformance: a thread to reply after");
123
+ const first = await reply(ctx, root.id, "conformance: after the root", { after: root.id });
124
+ await rejects(reply(ctx, root.id, "conformance: late", { after: root.id }), 409, "newer_message", "a reply after the root, once another came after it");
125
+ same((await ctx.board.thread(root.id)).replies.length, 1, "the replies after a refused one");
126
+ const deleted = await reply(ctx, root.id, "conformance: deleted", { after: first.id });
127
+ await ctx.board.delete(deleted.id);
128
+ await rejects(reply(ctx, root.id, "conformance: after a deleted one", { after: first.id }), 409, "newer_message", "a reply after a message a deleted one came after");
129
+ const racing = await Promise.allSettled([
130
+ 1,
131
+ 2,
132
+ 3
133
+ ].map((n) => reply(ctx, root.id, `conformance: racing ${n}`, { after: deleted.id })));
134
+ same(racing.filter((result) => result.status === "fulfilled").length, 1, "the replies that landed of three racing after the same message");
135
+ for (const result of racing) {
136
+ if (result.status === "fulfilled") continue;
137
+ const error = result.reason;
138
+ check(error instanceof BoardError && error.status === 409 && error.code === "newer_message", `a reply that lost the race should be 409 newer_message, got ${error instanceof BoardError ? `${error.status} ${error.code}` : String(error)}`);
139
+ }
140
+ same((await ctx.board.thread(root.id)).replies.length, 3, "the replies after the race, a deleted one included");
141
+ }
142
+ },
83
143
  {
84
144
  group: "core",
85
145
  name: "posts once for a client ID, however many times it is sent",
@@ -116,6 +176,78 @@ const CHECKS = [
116
176
  same((await version()).labels.state, void 0, "the label unset");
117
177
  }
118
178
  },
179
+ {
180
+ group: "core",
181
+ name: "labels a message only while its labels are as the write says, one of any that race",
182
+ run: async (ctx) => {
183
+ const message = await post(ctx, "conformance: a message to claim");
184
+ const labels = async () => (await ctx.board.thread(message.id)).message.labels;
185
+ const racing = await Promise.allSettled([
186
+ 1,
187
+ 2,
188
+ 3
189
+ ].map((n) => ctx.board.label(message.id, {
190
+ set: { owner: `agent-${n}` },
191
+ if: { owner: null }
192
+ })));
193
+ const landed = racing.flatMap((result, n) => result.status === "fulfilled" ? [`agent-${n + 1}`] : []);
194
+ same(landed.length, 1, "the claims that landed of three racing where no one had one");
195
+ for (const result of racing) {
196
+ if (result.status === "fulfilled") continue;
197
+ const error = result.reason;
198
+ check(error instanceof BoardError && error.status === 409 && error.code === "label_conflict", `a claim that lost the race should be 409 label_conflict, got ${error instanceof BoardError ? `${error.status} ${error.code}` : String(error)}`);
199
+ }
200
+ same((await labels()).owner, landed[0], "the owner, after the race");
201
+ await rejects(ctx.board.label(message.id, {
202
+ set: { owner: "someone" },
203
+ if: { owner: "no-one" }
204
+ }), 409, "label_conflict", "a label write from a value the label doesn't have");
205
+ same((await labels()).owner, landed[0], "the owner, after a write from the wrong value");
206
+ await ctx.board.label(message.id, {
207
+ unset: ["owner"],
208
+ if: { owner: landed[0] }
209
+ });
210
+ same((await labels()).owner, void 0, "the owner, released from the value it had");
211
+ }
212
+ },
213
+ {
214
+ group: "core",
215
+ name: "edits and deletes a message only at the revision it was read at, which only edits and deletes change",
216
+ run: async (ctx) => {
217
+ const message = await post(ctx, "conformance: a message to edit");
218
+ const read = async () => (await ctx.board.thread(message.id)).message;
219
+ const first = (await read()).revision;
220
+ check(typeof first === "number", `a message's revision should be a number, got ${show(first)}`);
221
+ await ctx.board.react(message.id, "eyes");
222
+ await ctx.board.label(message.id, { set: { state: "open" } });
223
+ same((await read()).revision, first, "the revision after a reaction and a label");
224
+ await ctx.board.edit(message.id, "conformance: edited", { revision: first });
225
+ const second = (await read()).revision;
226
+ check(second !== first, `an edit should change the revision from ${first}`);
227
+ await rejects(ctx.board.edit(message.id, "conformance: stale", { revision: first }), 409, "revision_conflict", "an edit at a revision an edit came after");
228
+ await rejects(ctx.board.delete(message.id, { revision: first }), 409, "revision_conflict", "a delete at a revision an edit came after");
229
+ const after = await read();
230
+ check(after.text === "conformance: edited" && !after.deleted, `refused writes should leave it as it was, got ${show({
231
+ text: after.text,
232
+ deleted: after.deleted
233
+ })}`);
234
+ const racing = await Promise.allSettled([
235
+ 1,
236
+ 2,
237
+ 3
238
+ ].map((n) => ctx.board.edit(message.id, `conformance: racing ${n}`, { revision: second })));
239
+ same(racing.filter((result) => result.status === "fulfilled").length, 1, "the edits that landed of three racing at the same revision");
240
+ for (const result of racing) {
241
+ if (result.status === "fulfilled") continue;
242
+ const error = result.reason;
243
+ check(error instanceof BoardError && error.status === 409 && error.code === "revision_conflict", `an edit that lost the race should be 409 revision_conflict, got ${error instanceof BoardError ? `${error.status} ${error.code}` : String(error)}`);
244
+ }
245
+ const last = (await read()).revision;
246
+ await ctx.board.delete(message.id, { revision: last });
247
+ check((await read()).deleted, "a delete at the revision it has should delete it");
248
+ await rejects(ctx.board.edit(message.id, "conformance: too late", { revision: last }), 409, "revision_conflict", "an edit at a revision a delete came after");
249
+ }
250
+ },
119
251
  {
120
252
  group: "core",
121
253
  name: "matches a label on the message or its thread's root",
@@ -261,6 +393,24 @@ const CHECKS = [
261
393
  same(canceled.state, "canceled", "the canceled schedule");
262
394
  }
263
395
  },
396
+ {
397
+ group: "schedules",
398
+ name: "keeps noSync on a schedule, so its sends stay off every platform",
399
+ run: async (ctx) => {
400
+ const { root } = needRoot(ctx);
401
+ const schedule = await ctx.board.schedule({ re: root.id }, {
402
+ text: "conformance: later, on the board only",
403
+ noSync: true,
404
+ in: "1d"
405
+ });
406
+ try {
407
+ same(schedule.noSync, true, "the schedule's noSync");
408
+ same((await ctx.board.schedules({ re: root.id })).find((s) => s.id === schedule.id)?.noSync, true, "the listed schedule's noSync");
409
+ } finally {
410
+ await ctx.board.cancelSchedule(schedule.id).catch(() => {});
411
+ }
412
+ }
413
+ },
264
414
  {
265
415
  group: "attachments",
266
416
  name: "takes a file, posts it, and gives it back",
@@ -341,7 +491,7 @@ const CHECKS = [
341
491
  group: "sync",
342
492
  name: "keeps sync's records: an import as a platform author, its lookups by platform ID, a receipt, and where it's synced",
343
493
  run: async (ctx) => {
344
- const key = "conformance";
494
+ const key = SYNC_KEY;
345
495
  const thread = `${key}:${ctx.labels.conformance}:1`;
346
496
  const author = {
347
497
  id: `${key}:user:${ctx.labels.conformance}`,
@@ -354,22 +504,9 @@ const CHECKS = [
354
504
  thread,
355
505
  message: "1"
356
506
  };
357
- const registered = (await ctx.board.watchers()).find((registration) => registration.owner === ctx.me.principal);
358
- if (registered && (Object.keys(registered.watch).length > 0 || (registered.sync?.length ?? 0) > 0)) return "this token has watchers or a sync registered here, which registering would replace";
359
- const where = wokenAt(registered);
360
- try {
361
- await ctx.board.register({
362
- url: null,
363
- watch: {},
364
- sync: [{
365
- key,
366
- platform: key
367
- }]
368
- });
369
- } catch (error) {
370
- if (error instanceof BoardError && error.status === 403) return "this token can't register a sync here";
371
- throw error;
372
- }
507
+ const registered = await registerSync(ctx);
508
+ if ("skip" in registered) return registered.skip;
509
+ const { where } = registered;
373
510
  let imported;
374
511
  try {
375
512
  imported = await ctx.board.syncImport({
@@ -477,6 +614,109 @@ const CHECKS = [
477
614
  }).catch(() => {});
478
615
  }
479
616
  }
617
+ },
618
+ {
619
+ group: "sync",
620
+ name: "queues a platform thread's events in its sync's inbox once, and leases each after the one before is acked",
621
+ run: async (ctx) => {
622
+ const registered = await registerSync(ctx);
623
+ if ("skip" in registered) return registered.skip;
624
+ try {
625
+ const run = ctx.labels.conformance;
626
+ const author = {
627
+ id: `${SYNC_KEY}:user:${run}`,
628
+ handle: "conformance",
629
+ name: "Conformance",
630
+ avatar: null,
631
+ isBot: false
632
+ };
633
+ const thread = `${SYNC_KEY}:${run}:inbox`;
634
+ const eventOf = (message) => ({
635
+ id: `conformance-inbox-${run}-${message}`,
636
+ event: {
637
+ kind: "message",
638
+ channel: `${SYNC_KEY}:${run}`,
639
+ thread,
640
+ subthread: null,
641
+ message,
642
+ isRoot: message === "1",
643
+ text: `conformance: inbox event ${message}`,
644
+ context: null,
645
+ author
646
+ },
647
+ raw: {
648
+ conformance: run,
649
+ message
650
+ }
651
+ });
652
+ const pushed = [eventOf("1"), eventOf("2")];
653
+ await ctx.board.inboxPush(SYNC_KEY, pushed);
654
+ await ctx.board.inboxPush(SYNC_KEY, [pushed[0]]);
655
+ const lease = async () => (await ctx.board.inboxNext(SYNC_KEY, { limit: 50 })).filter((item) => item.thread === thread);
656
+ for (const { event, raw } of pushed) {
657
+ const leased = await lease();
658
+ same(leased.length, 1, `the events leased from the platform thread, for message ${event.message}`);
659
+ const item = leased[0];
660
+ same(item.event.kind === "message" ? item.event.message : null, event.message, "the platform message leased next");
661
+ same(JSON.stringify(item.raw), JSON.stringify(raw), "the leased event's raw payload");
662
+ await ctx.board.inboxAck(SYNC_KEY, item.id, { lease: item.lease });
663
+ }
664
+ same((await lease()).length, 0, "the events leased once both are acked");
665
+ } finally {
666
+ await ctx.board.register({
667
+ ...registered.where,
668
+ watch: {}
669
+ }).catch(() => {});
670
+ }
671
+ }
672
+ },
673
+ {
674
+ group: "sync",
675
+ name: "hands a sync each message change as a match of its @sync watcher",
676
+ run: async (ctx) => {
677
+ const access = compileAccess(ctx.me.can);
678
+ if (access.anywhere("sync") && !access.can("sync", ctx.channel)) return `this token can't sync in ${ctx.channel}`;
679
+ const registered = await registerSync(ctx);
680
+ if ("skip" in registered) return registered.skip;
681
+ try {
682
+ const message = await post(ctx, "conformance: for a sync");
683
+ const deadline = Date.now() + 1e4;
684
+ let match;
685
+ while (!match && Date.now() < deadline) match = (await ctx.board.matches({ wait: 5 })).find((m) => m.watcher === "@sync" && m.event.message?.id === message.id);
686
+ check(match, "a pull should hand the sync a match for the post, as its @sync watcher's");
687
+ same(match.event.type, "message.posted", "the sync's match");
688
+ await ctx.board.ack(match.id, { lease: match.lease });
689
+ } finally {
690
+ await ctx.board.register({
691
+ ...registered.where,
692
+ watch: {}
693
+ }).catch(() => {});
694
+ }
695
+ }
696
+ },
697
+ {
698
+ group: "sync",
699
+ name: "keeps noSync on a post and a reply, so sync sends neither",
700
+ run: async (ctx) => {
701
+ const { root, reply } = needRoot(ctx);
702
+ const thread = await ctx.board.thread(root.id);
703
+ same(thread.message.noSync, true, "a post's noSync, read back");
704
+ same(thread.replies.find((m) => m.id === reply.id)?.noSync, true, "a reply's noSync, read back");
705
+ }
706
+ },
707
+ {
708
+ group: "sync",
709
+ name: "finds no one for a platform account nobody linked",
710
+ run: async (ctx) => {
711
+ let found;
712
+ try {
713
+ found = await ctx.board.syncFind({ account: `${SYNC_KEY}:user:${ctx.labels.conformance}` });
714
+ } catch (error) {
715
+ if (error instanceof BoardError && error.status === 403) return "this token can't look up a sync here";
716
+ throw error;
717
+ }
718
+ same(found.person, null, "who an account nobody linked is");
719
+ }
480
720
  }
481
721
  ];
482
722
  /** Runs the checks against `board`, in order, and reports each. It never throws for what the board answers. */
@@ -1,4 +1,4 @@
1
- import { d as Board } from "./backend-ZKYTe3xa.mjs";
1
+ import { d as Board } from "./backend-n3hajfrD.mjs";
2
2
  //#region src/conformance/index.d.ts
3
3
  /**
4
4
  * Checks that a board speaks the protocol, through a handle on it (`conformance(board())`), as
@@ -1,2 +1,2 @@
1
- import { t as conformance } from "./conformance-DlnfuzEB.mjs";
1
+ import { t as conformance } from "./conformance-C7fwIvmN.mjs";
2
2
  export { conformance };
@@ -1,14 +1,14 @@
1
1
  import { a as typeOf, i as fileSize, r as IMPORT_BYTES_MAX, t as ATTACHMENT_BYTES_MAX } from "./files-SniSmeEr.mjs";
2
- import { C as toStorable, c as ChannelPath, m as MESSAGE_TEXT_MAX } from "./schemas-Be08nyfW.mjs";
3
- import { c as externalMessageKey, l as isThreadTarget, s as channelOf } from "./external-BV_cUncs.mjs";
2
+ import { C as toStorable, c as ChannelPath, m as MESSAGE_TEXT_MAX } from "./schemas-DBmUO6pr.mjs";
3
+ import { c as externalMessageKey, l as isThreadTarget, s as channelOf } from "./external-D3p5T57R.mjs";
4
4
  import { t as canonicalEmoji } from "./emoji-CTLpvH-A.mjs";
5
+ import { r as badRequest, s as notFound, t as HttpError } from "./errors-C_5rB8Iz.mjs";
5
6
  import { r as derivedId, t as waitUntil } from "./wait-until-Dw4XrIDQ.mjs";
6
- import { r as badRequest, s as notFound, t as HttpError } from "./errors-CusN4x2l.mjs";
7
7
  import { n as replaceMentions } from "./mentions-CB6ShY4R.mjs";
8
- import { t as DroppedError } from "./middleware-ChKM1zuu.mjs";
8
+ import { t as DroppedError } from "./middleware-DlJQYiwV.mjs";
9
9
  import { t as afterRetry } from "./attempts-HXcskN1w.mjs";
10
- import { a as channelMissing, i as UnreadableError, n as ResolvedTarget, o as routesFor, t as ReceivedEvent } from "./provider-poIkXapd.mjs";
11
- import { a as immutable, n as SyncOverrides } from "./events-CewD4kw7.mjs";
10
+ import { a as channelMissing, i as UnreadableError, n as ResolvedTarget, o as routesFor, t as ReceivedEvent } from "./provider-BaVmi4Hi.mjs";
11
+ import { a as immutable, n as SyncOverrides } from "./events-qf5XG5VQ.mjs";
12
12
  import { t as displayText } from "./bots-CGkO76qH.mjs";
13
13
  //#region src/sync/engine.ts
14
14
  /** `files`: a message with files may have no text. */
@@ -872,7 +872,7 @@ function createSyncEngine(entries) {
872
872
  const root = await messageOf(rootId);
873
873
  const createdHere = root?.origin?.message === `lifecycle:${meta.id}`;
874
874
  const desired = labels((createdHere ? rootDraft({
875
- text: event.url ? `${event.title}\n${event.url}` : event.title,
875
+ text: lifecycleText(event),
876
876
  labels: event.labels,
877
877
  author: event.author,
878
878
  raw
@@ -883,7 +883,7 @@ function createSyncEngine(entries) {
883
883
  if (root && !root.deleted && (Object.keys(set).length || unset.length)) await setLabels(event.author, set, unset);
884
884
  if (event.retitled && root && !root.deleted && root.from === key && root.origin?.thread === event.thread) {
885
885
  const text = clampText(overrides.text ?? rootDraft({
886
- text: event.url ? `${event.title}\n${event.url}` : event.title,
886
+ text: lifecycleText(event),
887
887
  labels: root.labels,
888
888
  author: event.author,
889
889
  raw
@@ -916,7 +916,7 @@ function createSyncEngine(entries) {
916
916
  message: null
917
917
  };
918
918
  const draft = rootDraft({
919
- text: event.url ? `${event.title}\n${event.url}` : event.title,
919
+ text: lifecycleText(event),
920
920
  labels: event.labels,
921
921
  author: event.author,
922
922
  raw
@@ -1289,14 +1289,14 @@ function createSyncEngine(entries) {
1289
1289
  postedAsPerson = true;
1290
1290
  return asPerson;
1291
1291
  }
1292
- return platform.post(native(target), "channel", body);
1292
+ if (root.attachments.length === 0 || !platform.startsThreadsWithFiles) return platform.post(native(target), "channel", body);
1293
1293
  }
1294
1294
  try {
1295
1295
  const sent = await platform.post(native(target), "channel", {
1296
1296
  ...body,
1297
1297
  files: await messageFiles(root)
1298
1298
  });
1299
- uploaded = sent.files ?? [];
1299
+ uploaded = mediaOnly ? sent.files ?? [] : sent.files;
1300
1300
  return sent;
1301
1301
  } catch (error) {
1302
1302
  if (!platform.refusesFiles(error) && !keepsNoFiles(error)) throw error;
@@ -1500,10 +1500,13 @@ function createSyncEngine(entries) {
1500
1500
  return 403;
1501
1501
  };
1502
1502
  /** A platform channel or thread, read for the caller: nothing is synced, stored, or joined. */
1503
- async function readSource(key, target, limit) {
1503
+ async function readSource(key, target, limit, after) {
1504
1504
  const platform = platformOf(key);
1505
1505
  try {
1506
- return await platform.read(target, { limit });
1506
+ return await platform.read(target, {
1507
+ limit,
1508
+ after
1509
+ });
1507
1510
  } catch (error) {
1508
1511
  if (error instanceof UnreadableError) throw new HttpError(unreadableStatus(error.code), error.code, error.message);
1509
1512
  throw badRequest(`can't read ${target} from ${key}: ${describe(error)}`, "platform_error");
@@ -1542,5 +1545,10 @@ function createSyncEngine(entries) {
1542
1545
  joinSource
1543
1546
  };
1544
1547
  }
1548
+ /** A lifecycle root's text: the title, its link, and what the PR or issue says. */
1549
+ function lifecycleText(event) {
1550
+ const head = event.url ? `${event.title}\n${event.url}` : event.title;
1551
+ return event.body ? `${head}\n\n${event.body}` : head;
1552
+ }
1545
1553
  //#endregion
1546
1554
  export { createSyncEngine };
@@ -1,4 +1,4 @@
1
- import { r as ErrorOrigin } from "./client-xLxeDz0f.mjs";
1
+ import { r as ErrorOrigin } from "./client-CjAatpV4.mjs";
2
2
  //#region src/server/errors.d.ts
3
3
  declare class HttpError extends Error {
4
4
  readonly status: number;