@mpgd/adapter-devvit 0.4.0 → 0.6.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.
@@ -0,0 +1,857 @@
1
+ const operationSchemaVersion = 1;
2
+ const defaultLeaseTtlMs = 30000;
3
+ const defaultMaxPostDataBytes = 2048;
4
+ const defaultStoreAttempts = 8;
5
+ const maximumStoreAttempts = 32;
6
+ const maximumIdentifierLength = 128;
7
+ const maximumOperationTypeLength = 64;
8
+ const maximumLeaseTtlMs = 60 * 60 * 1000;
9
+ const operationTypePattern = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u;
10
+ const entryPattern = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/u;
11
+ const redditPostIdPattern = /^t3_[A-Za-z0-9]+$/u;
12
+ const redditSubredditIdPattern = /^t5_[a-z0-9]+$/u;
13
+ export class DevvitPostOperationValidationError extends TypeError {
14
+ constructor(message, options) {
15
+ super(message, options);
16
+ this.name = 'DevvitPostOperationValidationError';
17
+ }
18
+ }
19
+ export class DevvitPostOperationStateError extends Error {
20
+ constructor(message, options) {
21
+ super(message, options);
22
+ this.name = 'DevvitPostOperationStateError';
23
+ }
24
+ }
25
+ export function defineDevvitPostOperation(definition) {
26
+ assertOperationType(definition.operationType);
27
+ assertPositiveSafeInteger(definition.maxPostDataBytes ?? defaultMaxPostDataBytes, 'maxPostDataBytes');
28
+ if (typeof definition.parsePayload !== 'function') {
29
+ throw new DevvitPostOperationValidationError('parsePayload must be a function.');
30
+ }
31
+ if (typeof definition.parseLaunchParams !== 'function') {
32
+ throw new DevvitPostOperationValidationError('parseLaunchParams must be a function.');
33
+ }
34
+ return Object.freeze({ ...definition });
35
+ }
36
+ export function createDevvitPostOperationCoordinator(input) {
37
+ const definition = defineDevvitPostOperation(input.definition);
38
+ const now = input.now ?? Date.now;
39
+ const createToken = input.createToken ?? defaultTokenFactory;
40
+ const leaseTtlMs = input.leaseTtlMs ?? defaultLeaseTtlMs;
41
+ const storeAttempts = input.storeAttempts ?? defaultStoreAttempts;
42
+ assertPositiveSafeInteger(leaseTtlMs, 'leaseTtlMs');
43
+ if (leaseTtlMs > maximumLeaseTtlMs) {
44
+ throw new DevvitPostOperationValidationError(`leaseTtlMs must not exceed ${String(maximumLeaseTtlMs)}.`);
45
+ }
46
+ assertPositiveSafeInteger(storeAttempts, 'storeAttempts');
47
+ if (storeAttempts > maximumStoreAttempts) {
48
+ throw new DevvitPostOperationValidationError(`storeAttempts must not exceed ${String(maximumStoreAttempts)}.`);
49
+ }
50
+ return {
51
+ execute,
52
+ read,
53
+ reconcile,
54
+ };
55
+ async function execute(request) {
56
+ if (typeof request.publish !== 'function') {
57
+ throw new DevvitPostOperationValidationError('publish must be a function.');
58
+ }
59
+ const descriptor = createDescriptor(definition, request);
60
+ const operationKey = createDevvitPostOperationKey({
61
+ scope: request.scope,
62
+ operationType: definition.operationType,
63
+ operationId: request.operationId,
64
+ });
65
+ for (let attempt = 0; attempt < storeAttempts; attempt += 1) {
66
+ let current = await readOperation(operationKey, descriptor, definition, input.store);
67
+ if (current.kind === 'missing') {
68
+ const timestamp = readTimestamp(now);
69
+ const prepared = {
70
+ schemaVersion: operationSchemaVersion,
71
+ revision: 0,
72
+ operationKey,
73
+ phase: 'prepared',
74
+ descriptor,
75
+ createdAt: timestamp,
76
+ updatedAt: timestamp,
77
+ };
78
+ const preparedRaw = serializeRecord(prepared);
79
+ if (!await input.store.create(operationKey, preparedRaw)) {
80
+ continue;
81
+ }
82
+ current = { kind: 'record', raw: preparedRaw, record: prepared };
83
+ }
84
+ if (current.kind === 'conflict') {
85
+ return conflictResult(descriptor);
86
+ }
87
+ switch (current.record.phase) {
88
+ case 'prepared': {
89
+ const timestamp = Math.max(readTimestamp(now), current.record.updatedAt);
90
+ const attempted = {
91
+ ...current.record,
92
+ revision: current.record.revision + 1,
93
+ phase: 'attempted',
94
+ attemptId: readToken(createToken, 'submission attempt'),
95
+ attemptedAt: timestamp,
96
+ updatedAt: timestamp,
97
+ };
98
+ const attemptedRaw = serializeRecord(attempted);
99
+ if (!await input.store.compareAndSet(operationKey, current.raw, attemptedRaw)) {
100
+ continue;
101
+ }
102
+ return submitOnce(attempted, attemptedRaw, request.publish);
103
+ }
104
+ case 'attempted':
105
+ return reconciliationResult(current.record, 'submission-attempted');
106
+ case 'published':
107
+ return publishedResult(current.record, 'existing');
108
+ case 'terminal-unresolved':
109
+ return terminalResult(current.record);
110
+ }
111
+ }
112
+ return busyResult(descriptor, 'store-contention');
113
+ }
114
+ async function read(request) {
115
+ const descriptor = createDescriptor(definition, request);
116
+ const operationKey = createDevvitPostOperationKey({
117
+ scope: request.scope,
118
+ operationType: definition.operationType,
119
+ operationId: request.operationId,
120
+ });
121
+ const current = await readOperation(operationKey, descriptor, definition, input.store);
122
+ if (current.kind === 'missing') {
123
+ return missingResult(descriptor);
124
+ }
125
+ if (current.kind === 'conflict') {
126
+ return conflictResult(descriptor);
127
+ }
128
+ switch (current.record.phase) {
129
+ case 'prepared':
130
+ return preparedResult(current.record);
131
+ case 'attempted':
132
+ return reconciliationResult(current.record, 'submission-attempted');
133
+ case 'published':
134
+ return publishedResult(current.record, 'existing');
135
+ case 'terminal-unresolved':
136
+ return terminalResult(current.record);
137
+ }
138
+ }
139
+ async function reconcile(request) {
140
+ if (typeof request.findCandidates !== 'function') {
141
+ throw new DevvitPostOperationValidationError('findCandidates must be a function.');
142
+ }
143
+ const descriptor = createDescriptor(definition, request);
144
+ const operationKey = createDevvitPostOperationKey({
145
+ scope: request.scope,
146
+ operationType: definition.operationType,
147
+ operationId: request.operationId,
148
+ });
149
+ const current = await readOperation(operationKey, descriptor, definition, input.store);
150
+ if (current.kind === 'missing') {
151
+ return missingResult(descriptor);
152
+ }
153
+ if (current.kind === 'conflict') {
154
+ return conflictResult(descriptor);
155
+ }
156
+ if (current.record.phase === 'prepared') {
157
+ return preparedResult(current.record);
158
+ }
159
+ if (current.record.phase === 'published') {
160
+ return publishedResult(current.record, 'existing');
161
+ }
162
+ if (current.record.phase === 'terminal-unresolved') {
163
+ return terminalResult(current.record);
164
+ }
165
+ const leaseKey = `${operationKey}:reconciliation-lease`;
166
+ const leaseToken = readToken(createToken, 'reconciliation lease');
167
+ const leaseExpirationMs = readTimestamp(now) + leaseTtlMs;
168
+ const leaseExpiration = new Date(leaseExpirationMs);
169
+ if (!Number.isSafeInteger(leaseExpirationMs) || Number.isNaN(leaseExpiration.getTime())) {
170
+ throw new DevvitPostOperationValidationError('The reconciliation lease expiration must be a valid timestamp.');
171
+ }
172
+ if (!await input.store.createLease(leaseKey, leaseToken, leaseExpiration)) {
173
+ return busyResult(descriptor, 'reconciliation-lease-held');
174
+ }
175
+ try {
176
+ let candidates;
177
+ try {
178
+ candidates = await request.findCandidates(toPublishInput(descriptor));
179
+ }
180
+ catch {
181
+ return reconciliationResult(current.record, 'reconciliation-failed');
182
+ }
183
+ if (!Array.isArray(candidates)) {
184
+ return reconciliationResult(current.record, 'invalid-reconciliation-candidate');
185
+ }
186
+ let exactMatches;
187
+ try {
188
+ exactMatches = exactReconciliationMatches(candidates, descriptor, definition);
189
+ }
190
+ catch (error) {
191
+ if (error instanceof DevvitPostOperationValidationError) {
192
+ return reconciliationResult(current.record, 'invalid-reconciliation-candidate');
193
+ }
194
+ throw error;
195
+ }
196
+ if (exactMatches.length === 0) {
197
+ return reconciliationResult(current.record, 'match-not-found');
198
+ }
199
+ if (exactMatches.length === 1) {
200
+ const [match] = exactMatches;
201
+ if (match === undefined) {
202
+ throw new DevvitPostOperationStateError('Expected one reconciliation match.');
203
+ }
204
+ return commitPublished(current.record, current.raw, match.postId, true, 'recovered');
205
+ }
206
+ return commitTerminal(current.record, current.raw, 'multiple-exact-matches', exactMatches.map((candidate) => candidate.postId));
207
+ }
208
+ finally {
209
+ try {
210
+ await input.store.releaseLease(leaseKey, leaseToken);
211
+ }
212
+ catch {
213
+ // The fenced lease expires on its own; cleanup must not mask a durable result.
214
+ }
215
+ }
216
+ }
217
+ async function submitOnce(record, raw, publish) {
218
+ let receipt;
219
+ try {
220
+ receipt = await publish(toPublishInput(record.descriptor));
221
+ }
222
+ catch {
223
+ return reconciliationResult(record, 'submit-outcome-unknown');
224
+ }
225
+ if (!isRedditPostId(receipt?.postId)) {
226
+ return reconciliationResult(record, 'invalid-publish-receipt');
227
+ }
228
+ try {
229
+ return await commitPublished(record, raw, receipt.postId, false, 'created');
230
+ }
231
+ catch {
232
+ return reconciliationResult(record, 'receipt-write-failed');
233
+ }
234
+ }
235
+ async function commitPublished(source, sourceRaw, postId, recovered, successStatus) {
236
+ let expectedRecord = source;
237
+ let expectedRaw = sourceRaw;
238
+ for (let attempt = 0; attempt < storeAttempts; attempt += 1) {
239
+ const timestamp = Math.max(readTimestamp(now), expectedRecord.updatedAt);
240
+ const published = {
241
+ schemaVersion: operationSchemaVersion,
242
+ revision: expectedRecord.revision + 1,
243
+ operationKey: expectedRecord.operationKey,
244
+ phase: 'published',
245
+ descriptor: expectedRecord.descriptor,
246
+ createdAt: expectedRecord.createdAt,
247
+ updatedAt: timestamp,
248
+ attemptId: source.attemptId,
249
+ attemptedAt: source.attemptedAt,
250
+ postId,
251
+ publishedAt: timestamp,
252
+ recovered,
253
+ };
254
+ if (await input.store.compareAndSet(source.operationKey, expectedRaw, serializeRecord(published))) {
255
+ return publishedResult(published, successStatus);
256
+ }
257
+ const reread = await readOperation(source.operationKey, source.descriptor, definition, input.store);
258
+ if (reread.kind === 'missing' || reread.kind === 'conflict') {
259
+ throw new DevvitPostOperationStateError(`Durable post operation changed incompatibly while recording post ${postId}.`);
260
+ }
261
+ if (reread.record.phase === 'published') {
262
+ if (reread.record.postId === postId) {
263
+ return publishedResult(reread.record, successStatus);
264
+ }
265
+ const conflictingPostIds = [reread.record.postId, postId];
266
+ return commitTerminal(reread.record, reread.raw, 'published-receipt-conflict', conflictingPostIds);
267
+ }
268
+ if (reread.record.phase === 'terminal-unresolved') {
269
+ return terminalResult(reread.record);
270
+ }
271
+ if (reread.record.phase !== 'attempted' || reread.record.attemptId !== source.attemptId) {
272
+ throw new DevvitPostOperationStateError(`Durable post operation lost its permanent attempt fence for post ${postId}.`);
273
+ }
274
+ expectedRecord = reread.record;
275
+ expectedRaw = reread.raw;
276
+ }
277
+ return busyResult(source.descriptor, 'store-contention');
278
+ }
279
+ async function commitTerminal(source, sourceRaw, reason, postIds) {
280
+ const normalizedPostIds = [...new Set(postIds)].sort();
281
+ if (normalizedPostIds.length < 2) {
282
+ throw new DevvitPostOperationStateError('A terminal post operation requires at least two distinct post IDs.');
283
+ }
284
+ let expectedRecord = source;
285
+ let expectedRaw = sourceRaw;
286
+ let expectedPostIds = normalizedPostIds;
287
+ // One extra convergence attempt preserves ambiguity evidence when the first
288
+ // CAS loses specifically to an attempted -> published transition.
289
+ for (let attempt = 0; attempt < storeAttempts + 1; attempt += 1) {
290
+ const timestamp = Math.max(readTimestamp(now), expectedRecord.updatedAt);
291
+ const terminal = {
292
+ schemaVersion: operationSchemaVersion,
293
+ revision: expectedRecord.revision + 1,
294
+ operationKey: expectedRecord.operationKey,
295
+ phase: 'terminal-unresolved',
296
+ descriptor: expectedRecord.descriptor,
297
+ createdAt: expectedRecord.createdAt,
298
+ updatedAt: timestamp,
299
+ attemptId: expectedRecord.attemptId,
300
+ attemptedAt: expectedRecord.attemptedAt,
301
+ reason,
302
+ postIds: expectedPostIds,
303
+ unresolvedAt: timestamp,
304
+ };
305
+ if (await input.store.compareAndSet(source.operationKey, expectedRaw, serializeRecord(terminal))) {
306
+ return terminalResult(terminal);
307
+ }
308
+ const current = await readOperation(source.operationKey, source.descriptor, definition, input.store);
309
+ if (current.kind !== 'record') {
310
+ throw new DevvitPostOperationStateError('Durable post operation changed incompatibly while recording a terminal outcome.');
311
+ }
312
+ if (current.record.phase === 'terminal-unresolved') {
313
+ return terminalResult(current.record);
314
+ }
315
+ if (current.record.phase === 'prepared'
316
+ || current.record.attemptId !== source.attemptId) {
317
+ throw new DevvitPostOperationStateError('Durable post operation lost its permanent attempt fence while recording a terminal outcome.');
318
+ }
319
+ expectedRecord = current.record;
320
+ expectedRaw = current.raw;
321
+ if (current.record.phase === 'published') {
322
+ expectedPostIds = [...new Set([...expectedPostIds, current.record.postId])].sort();
323
+ }
324
+ }
325
+ return busyResult(source.descriptor, 'store-contention');
326
+ }
327
+ }
328
+ export function createDevvitPostOperationKey(input) {
329
+ const appScope = assertIdentifier(input.scope.appScope, 'appScope');
330
+ const subredditId = assertSubredditId(input.scope.subredditId);
331
+ const operationId = assertIdentifier(input.operationId, 'operationId');
332
+ assertOperationType(input.operationType);
333
+ return [
334
+ 'mpgd:devvit:post-operation:v1',
335
+ encodeKeyComponent(appScope),
336
+ encodeKeyComponent(subredditId),
337
+ encodeKeyComponent(input.operationType),
338
+ encodeKeyComponent(operationId),
339
+ 'state',
340
+ ].join(':');
341
+ }
342
+ function createDescriptor(definition, input) {
343
+ const scope = {
344
+ appScope: assertIdentifier(input.scope.appScope, 'appScope'),
345
+ subredditId: assertSubredditId(input.scope.subredditId),
346
+ };
347
+ const operationId = assertIdentifier(input.operationId, 'operationId');
348
+ const entry = assertEntry(input.launch.entry);
349
+ const payload = parseJsonObject(definition.parsePayload, input.payload, 'payload');
350
+ const params = parseJsonObject(definition.parseLaunchParams, input.launch.params, 'launch.params');
351
+ const descriptor = {
352
+ mpgd: {
353
+ schemaVersion: operationSchemaVersion,
354
+ appScope: scope.appScope,
355
+ subredditId: scope.subredditId,
356
+ operationType: definition.operationType,
357
+ operationId,
358
+ },
359
+ launch: {
360
+ schemaVersion: operationSchemaVersion,
361
+ entry,
362
+ params,
363
+ },
364
+ payload,
365
+ };
366
+ assertPostDataByteLength(descriptor, definition.maxPostDataBytes ?? defaultMaxPostDataBytes);
367
+ return freezeJsonValue(descriptor);
368
+ }
369
+ async function readOperation(operationKey, descriptor, definition, store) {
370
+ const raw = await store.read(operationKey);
371
+ if (raw === undefined) {
372
+ return { kind: 'missing' };
373
+ }
374
+ const record = parseStoredRecord(raw, operationKey, definition);
375
+ if (canonicalJson(record.descriptor) !== canonicalJson(descriptor)) {
376
+ return { kind: 'conflict' };
377
+ }
378
+ return { kind: 'record', raw, record };
379
+ }
380
+ function parseStoredRecord(raw, operationKey, definition) {
381
+ let parsed;
382
+ try {
383
+ parsed = JSON.parse(raw);
384
+ }
385
+ catch (error) {
386
+ throw new DevvitPostOperationStateError('Stored Devvit post operation is not valid JSON.', {
387
+ cause: error,
388
+ });
389
+ }
390
+ try {
391
+ const record = assertObject(parsed, 'stored operation');
392
+ const phase = assertString(record.phase, 'stored operation.phase');
393
+ const commonKeys = [
394
+ 'schemaVersion',
395
+ 'revision',
396
+ 'operationKey',
397
+ 'phase',
398
+ 'descriptor',
399
+ 'createdAt',
400
+ 'updatedAt',
401
+ ];
402
+ let phaseKeys;
403
+ switch (phase) {
404
+ case 'prepared':
405
+ phaseKeys = [];
406
+ break;
407
+ case 'attempted':
408
+ phaseKeys = ['attemptId', 'attemptedAt'];
409
+ break;
410
+ case 'published':
411
+ phaseKeys = ['attemptId', 'attemptedAt', 'postId', 'publishedAt', 'recovered'];
412
+ break;
413
+ case 'terminal-unresolved':
414
+ phaseKeys = ['attemptId', 'attemptedAt', 'reason', 'postIds', 'unresolvedAt'];
415
+ break;
416
+ default:
417
+ throw new DevvitPostOperationValidationError(`Unsupported stored operation phase: ${phase}`);
418
+ }
419
+ assertExactKeys(record, [...commonKeys, ...phaseKeys], 'stored operation');
420
+ assertEqual(record.schemaVersion, operationSchemaVersion, 'stored operation.schemaVersion');
421
+ const revision = assertNonNegativeSafeInteger(record.revision, 'stored operation.revision');
422
+ assertEqual(record.operationKey, operationKey, 'stored operation.operationKey');
423
+ const descriptor = parseCanonicalPostData(record.descriptor, definition);
424
+ const createdAt = assertTimestamp(record.createdAt, 'stored operation.createdAt');
425
+ const updatedAt = assertTimestamp(record.updatedAt, 'stored operation.updatedAt');
426
+ if (updatedAt < createdAt) {
427
+ throw new DevvitPostOperationValidationError('stored operation.updatedAt must not precede createdAt.');
428
+ }
429
+ if (phase === 'prepared') {
430
+ assertEqual(revision, 0, 'prepared operation revision');
431
+ if (updatedAt !== createdAt) {
432
+ throw new DevvitPostOperationValidationError('Prepared operation timestamps must be equal.');
433
+ }
434
+ return {
435
+ schemaVersion: operationSchemaVersion,
436
+ revision,
437
+ operationKey,
438
+ phase,
439
+ descriptor,
440
+ createdAt,
441
+ updatedAt,
442
+ };
443
+ }
444
+ const attemptId = assertIdentifier(record.attemptId, 'stored operation.attemptId');
445
+ const attemptedAt = assertTimestamp(record.attemptedAt, 'stored operation.attemptedAt');
446
+ if (revision < 1 || attemptedAt < createdAt || updatedAt < attemptedAt) {
447
+ throw new DevvitPostOperationValidationError('Stored attempt metadata is inconsistent.');
448
+ }
449
+ if (phase === 'attempted') {
450
+ assertEqual(revision, 1, 'attempted operation revision');
451
+ if (updatedAt !== attemptedAt) {
452
+ throw new DevvitPostOperationValidationError('Attempted operation timestamps must be equal.');
453
+ }
454
+ return {
455
+ schemaVersion: operationSchemaVersion,
456
+ revision,
457
+ operationKey,
458
+ phase,
459
+ descriptor,
460
+ createdAt,
461
+ updatedAt,
462
+ attemptId,
463
+ attemptedAt,
464
+ };
465
+ }
466
+ if (phase === 'published') {
467
+ assertEqual(revision, 2, 'published operation revision');
468
+ const postId = assertPostId(record.postId, 'stored operation.postId');
469
+ const publishedAt = assertTimestamp(record.publishedAt, 'stored operation.publishedAt');
470
+ const recovered = assertBoolean(record.recovered, 'stored operation.recovered');
471
+ if (publishedAt < attemptedAt || updatedAt !== publishedAt) {
472
+ throw new DevvitPostOperationValidationError('Stored publication timestamps are inconsistent.');
473
+ }
474
+ return {
475
+ schemaVersion: operationSchemaVersion,
476
+ revision,
477
+ operationKey,
478
+ phase,
479
+ descriptor,
480
+ createdAt,
481
+ updatedAt,
482
+ attemptId,
483
+ attemptedAt,
484
+ postId,
485
+ publishedAt,
486
+ recovered,
487
+ };
488
+ }
489
+ const reason = record.reason;
490
+ if (reason !== 'multiple-exact-matches' && reason !== 'published-receipt-conflict') {
491
+ throw new DevvitPostOperationValidationError('Stored terminal reason is not supported.');
492
+ }
493
+ if (revision !== 2 && revision !== 3) {
494
+ throw new DevvitPostOperationValidationError('terminal operation revision must be 2 or 3.');
495
+ }
496
+ if (reason === 'published-receipt-conflict' && revision !== 3) {
497
+ throw new DevvitPostOperationValidationError('A published receipt conflict must follow a published state.');
498
+ }
499
+ if (!Array.isArray(record.postIds) || record.postIds.length < 2) {
500
+ throw new DevvitPostOperationValidationError('Stored terminal operation must contain at least two post IDs.');
501
+ }
502
+ const postIds = record.postIds.map((postId, index) => assertPostId(postId, `stored operation.postIds[${String(index)}]`));
503
+ if (new Set(postIds).size !== postIds.length) {
504
+ throw new DevvitPostOperationValidationError('Stored terminal post IDs must be unique.');
505
+ }
506
+ const unresolvedAt = assertTimestamp(record.unresolvedAt, 'stored operation.unresolvedAt');
507
+ if (unresolvedAt < attemptedAt || updatedAt !== unresolvedAt) {
508
+ throw new DevvitPostOperationValidationError('Stored terminal timestamps are inconsistent.');
509
+ }
510
+ return {
511
+ schemaVersion: operationSchemaVersion,
512
+ revision,
513
+ operationKey,
514
+ phase: 'terminal-unresolved',
515
+ descriptor,
516
+ createdAt,
517
+ updatedAt,
518
+ attemptId,
519
+ attemptedAt,
520
+ reason,
521
+ postIds,
522
+ unresolvedAt,
523
+ };
524
+ }
525
+ catch (error) {
526
+ if (error instanceof DevvitPostOperationStateError) {
527
+ throw error;
528
+ }
529
+ throw new DevvitPostOperationStateError('Stored Devvit post operation is invalid.', {
530
+ cause: error,
531
+ });
532
+ }
533
+ }
534
+ function parseCanonicalPostData(input, definition) {
535
+ const postData = assertObject(input, 'postData');
536
+ assertExactKeys(postData, ['mpgd', 'launch', 'payload'], 'postData');
537
+ const marker = assertObject(postData.mpgd, 'postData.mpgd');
538
+ assertExactKeys(marker, ['schemaVersion', 'appScope', 'subredditId', 'operationType', 'operationId'], 'postData.mpgd');
539
+ assertEqual(marker.schemaVersion, operationSchemaVersion, 'postData.mpgd.schemaVersion');
540
+ const operationType = assertString(marker.operationType, 'postData.mpgd.operationType');
541
+ assertEqual(operationType, definition.operationType, 'postData.mpgd.operationType');
542
+ const launch = assertObject(postData.launch, 'postData.launch');
543
+ assertExactKeys(launch, ['schemaVersion', 'entry', 'params'], 'postData.launch');
544
+ assertEqual(launch.schemaVersion, operationSchemaVersion, 'postData.launch.schemaVersion');
545
+ const parsed = {
546
+ mpgd: {
547
+ schemaVersion: operationSchemaVersion,
548
+ appScope: assertIdentifier(marker.appScope, 'postData.mpgd.appScope'),
549
+ subredditId: assertSubredditId(marker.subredditId),
550
+ operationType,
551
+ operationId: assertIdentifier(marker.operationId, 'postData.mpgd.operationId'),
552
+ },
553
+ launch: {
554
+ schemaVersion: operationSchemaVersion,
555
+ entry: assertEntry(launch.entry),
556
+ params: parseJsonObject(definition.parseLaunchParams, launch.params, 'postData.launch.params'),
557
+ },
558
+ payload: parseJsonObject(definition.parsePayload, postData.payload, 'postData.payload'),
559
+ };
560
+ assertPostDataByteLength(parsed, definition.maxPostDataBytes ?? defaultMaxPostDataBytes);
561
+ return freezeJsonValue(parsed);
562
+ }
563
+ function exactReconciliationMatches(candidates, descriptor, definition) {
564
+ const expected = canonicalJson(descriptor);
565
+ const exact = new Map();
566
+ for (const candidate of candidates) {
567
+ const candidateRecord = assertObject(candidate, 'reconciliation candidate');
568
+ assertExactKeys(candidateRecord, ['postId', 'postData'], 'reconciliation candidate');
569
+ const postId = assertPostId(candidateRecord.postId, 'reconciliation candidate.postId');
570
+ if (!targetsDescriptor(candidateRecord.postData, descriptor)) {
571
+ continue;
572
+ }
573
+ const postData = parseCanonicalPostData(candidateRecord.postData, definition);
574
+ if (canonicalJson(postData) === expected) {
575
+ exact.set(postId, { postId, postData });
576
+ }
577
+ }
578
+ return [...exact.values()];
579
+ }
580
+ function targetsDescriptor(input, descriptor) {
581
+ if (!isPlainObject(input) || !isPlainObject(input.mpgd)) {
582
+ return false;
583
+ }
584
+ const marker = input.mpgd;
585
+ return marker.schemaVersion === operationSchemaVersion
586
+ && marker.appScope === descriptor.mpgd.appScope
587
+ && marker.subredditId === descriptor.mpgd.subredditId
588
+ && marker.operationType === descriptor.mpgd.operationType
589
+ && marker.operationId === descriptor.mpgd.operationId;
590
+ }
591
+ function serializeRecord(record) {
592
+ return canonicalJson(record);
593
+ }
594
+ function canonicalJson(input) {
595
+ if (input === null || typeof input === 'boolean' || typeof input === 'string') {
596
+ return JSON.stringify(input);
597
+ }
598
+ if (typeof input === 'number') {
599
+ if (!Number.isFinite(input)) {
600
+ throw new DevvitPostOperationValidationError('JSON numbers must be finite.');
601
+ }
602
+ return JSON.stringify(input);
603
+ }
604
+ if (Array.isArray(input)) {
605
+ return `[${input.map((value) => canonicalJson(value)).join(',')}]`;
606
+ }
607
+ const object = input;
608
+ return `{${Object.keys(object)
609
+ .sort()
610
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`)
611
+ .join(',')}}`;
612
+ }
613
+ function parseJsonObject(parser, input, label) {
614
+ let parsed;
615
+ try {
616
+ parsed = parser(input);
617
+ }
618
+ catch (error) {
619
+ throw new DevvitPostOperationValidationError(`${label} did not pass its parser.`, {
620
+ cause: error,
621
+ });
622
+ }
623
+ if (!isPlainObject(parsed)) {
624
+ throw new DevvitPostOperationValidationError(`${label} parser must return a plain object.`);
625
+ }
626
+ normalizeJsonValue(parsed, label, new Set());
627
+ return freezeJsonValue(JSON.parse(canonicalJson(parsed)));
628
+ }
629
+ function freezeJsonValue(input) {
630
+ if (Array.isArray(input)) {
631
+ for (const value of input) {
632
+ freezeJsonValue(value);
633
+ }
634
+ return Object.freeze(input);
635
+ }
636
+ if (isPlainObject(input)) {
637
+ for (const value of Object.values(input)) {
638
+ freezeJsonValue(value);
639
+ }
640
+ return Object.freeze(input);
641
+ }
642
+ return input;
643
+ }
644
+ function normalizeJsonValue(input, label, ancestors) {
645
+ if (input === null || typeof input === 'boolean' || typeof input === 'string') {
646
+ return input;
647
+ }
648
+ if (typeof input === 'number') {
649
+ if (!Number.isFinite(input)) {
650
+ throw new DevvitPostOperationValidationError(`${label} contains a non-finite number.`);
651
+ }
652
+ return input;
653
+ }
654
+ if (typeof input !== 'object') {
655
+ throw new DevvitPostOperationValidationError(`${label} must contain only JSON values.`);
656
+ }
657
+ if (ancestors.has(input)) {
658
+ throw new DevvitPostOperationValidationError(`${label} must not contain cycles.`);
659
+ }
660
+ ancestors.add(input);
661
+ if (Array.isArray(input)) {
662
+ for (let index = 0; index < input.length; index += 1) {
663
+ normalizeJsonValue(input[index], `${label}[${String(index)}]`, ancestors);
664
+ }
665
+ }
666
+ else {
667
+ const prototype = Object.getPrototypeOf(input);
668
+ if (prototype !== Object.prototype && prototype !== null) {
669
+ throw new DevvitPostOperationValidationError(`${label} must use plain JSON objects.`);
670
+ }
671
+ for (const [key, value] of Object.entries(input)) {
672
+ normalizeJsonValue(value, `${label}.${key}`, ancestors);
673
+ }
674
+ }
675
+ ancestors.delete(input);
676
+ return input;
677
+ }
678
+ function toPublishInput(descriptor) {
679
+ return Object.freeze({
680
+ scope: Object.freeze({
681
+ appScope: descriptor.mpgd.appScope,
682
+ subredditId: descriptor.mpgd.subredditId,
683
+ }),
684
+ operationId: descriptor.mpgd.operationId,
685
+ postData: descriptor,
686
+ });
687
+ }
688
+ function missingResult(descriptor) {
689
+ return { status: 'missing', operationId: descriptor.mpgd.operationId, postData: descriptor };
690
+ }
691
+ function preparedResult(record) {
692
+ return {
693
+ status: 'prepared',
694
+ operationId: record.descriptor.mpgd.operationId,
695
+ postData: record.descriptor,
696
+ };
697
+ }
698
+ function publishedResult(record, status) {
699
+ return {
700
+ status,
701
+ operationId: record.descriptor.mpgd.operationId,
702
+ postId: record.postId,
703
+ postData: record.descriptor,
704
+ };
705
+ }
706
+ function reconciliationResult(record, reason) {
707
+ return {
708
+ status: 'reconciliation-required',
709
+ operationId: record.descriptor.mpgd.operationId,
710
+ reason,
711
+ postData: record.descriptor,
712
+ };
713
+ }
714
+ function busyResult(descriptor, reason) {
715
+ return {
716
+ status: 'busy',
717
+ operationId: descriptor.mpgd.operationId,
718
+ reason,
719
+ postData: descriptor,
720
+ };
721
+ }
722
+ function conflictResult(descriptor) {
723
+ return {
724
+ status: 'conflict',
725
+ operationId: descriptor.mpgd.operationId,
726
+ reason: 'descriptor-mismatch',
727
+ postData: descriptor,
728
+ };
729
+ }
730
+ function terminalResult(record) {
731
+ return {
732
+ status: 'terminal-unresolved',
733
+ operationId: record.descriptor.mpgd.operationId,
734
+ reason: record.reason,
735
+ postIds: record.postIds,
736
+ postData: record.descriptor,
737
+ };
738
+ }
739
+ function assertObject(input, label) {
740
+ if (!isPlainObject(input)) {
741
+ throw new DevvitPostOperationValidationError(`${label} must be an object.`);
742
+ }
743
+ return input;
744
+ }
745
+ function isPlainObject(input) {
746
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
747
+ return false;
748
+ }
749
+ const prototype = Object.getPrototypeOf(input);
750
+ return prototype === Object.prototype || prototype === null;
751
+ }
752
+ function assertExactKeys(input, expectedKeys, label) {
753
+ const actual = Object.keys(input).sort();
754
+ const expected = [...expectedKeys].sort();
755
+ if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
756
+ throw new DevvitPostOperationValidationError(`${label} has unexpected or missing fields.`);
757
+ }
758
+ }
759
+ function assertString(input, label) {
760
+ if (typeof input !== 'string' || input.length === 0) {
761
+ throw new DevvitPostOperationValidationError(`${label} must be a non-empty string.`);
762
+ }
763
+ return input;
764
+ }
765
+ function assertIdentifier(input, label) {
766
+ const value = assertString(input, label);
767
+ if (value !== value.trim() || value.length > maximumIdentifierLength) {
768
+ throw new DevvitPostOperationValidationError(`${label} must be trimmed and at most ${String(maximumIdentifierLength)} characters.`);
769
+ }
770
+ return value;
771
+ }
772
+ function assertOperationType(input) {
773
+ const value = assertString(input, 'operationType');
774
+ if (value.length > maximumOperationTypeLength || !operationTypePattern.test(value)) {
775
+ throw new DevvitPostOperationValidationError(`operationType must match ${String(operationTypePattern)} and be at most ${String(maximumOperationTypeLength)} characters.`);
776
+ }
777
+ }
778
+ function assertEntry(input) {
779
+ const value = assertIdentifier(input, 'launch.entry');
780
+ const hasUnsafeSegment = value
781
+ .split('/')
782
+ .some((segment) => segment === '' || segment === '.' || segment === '..');
783
+ if (!entryPattern.test(value) || hasUnsafeSegment) {
784
+ const requirement = `match ${String(entryPattern)} without empty, dot, or dot-dot segments`;
785
+ throw new DevvitPostOperationValidationError(`launch.entry must ${requirement}.`);
786
+ }
787
+ return value;
788
+ }
789
+ function assertSubredditId(input) {
790
+ const value = assertIdentifier(input, 'subredditId');
791
+ if (!redditSubredditIdPattern.test(value)) {
792
+ throw new DevvitPostOperationValidationError('subredditId must be a canonical lowercase Reddit t5 fullname.');
793
+ }
794
+ return value;
795
+ }
796
+ function assertPostId(input, label) {
797
+ const value = assertString(input, label);
798
+ if (!isRedditPostId(value)) {
799
+ throw new DevvitPostOperationValidationError(`${label} must be a Reddit t3 post ID.`);
800
+ }
801
+ return value;
802
+ }
803
+ function isRedditPostId(input) {
804
+ return typeof input === 'string' && redditPostIdPattern.test(input);
805
+ }
806
+ function assertBoolean(input, label) {
807
+ if (typeof input !== 'boolean') {
808
+ throw new DevvitPostOperationValidationError(`${label} must be a boolean.`);
809
+ }
810
+ return input;
811
+ }
812
+ function assertTimestamp(input, label) {
813
+ return assertNonNegativeSafeInteger(input, label);
814
+ }
815
+ function assertNonNegativeSafeInteger(input, label) {
816
+ if (!Number.isSafeInteger(input) || input < 0) {
817
+ throw new DevvitPostOperationValidationError(`${label} must be a non-negative safe integer.`);
818
+ }
819
+ return input;
820
+ }
821
+ function assertPositiveSafeInteger(input, label) {
822
+ if (!Number.isSafeInteger(input) || input < 1) {
823
+ throw new DevvitPostOperationValidationError(`${label} must be a positive safe integer.`);
824
+ }
825
+ }
826
+ function assertEqual(input, expected, label) {
827
+ if (input !== expected) {
828
+ throw new DevvitPostOperationValidationError(`${label} does not match the expected value.`);
829
+ }
830
+ }
831
+ function assertPostDataByteLength(postData, maximumBytes) {
832
+ const bytes = new TextEncoder().encode(canonicalJson(postData)).byteLength;
833
+ if (bytes > maximumBytes) {
834
+ throw new DevvitPostOperationValidationError(`Canonical Devvit post data is ${String(bytes)} bytes; maximum is ${String(maximumBytes)}.`);
835
+ }
836
+ }
837
+ function encodeKeyComponent(value) {
838
+ const encoded = encodeURIComponent(value);
839
+ const byteLength = new TextEncoder().encode(value).byteLength;
840
+ return `${String(byteLength)}-${encoded}`;
841
+ }
842
+ function readTimestamp(now) {
843
+ const value = now();
844
+ if (!Number.isSafeInteger(value) || value < 0) {
845
+ throw new DevvitPostOperationValidationError('now must return a non-negative safe integer.');
846
+ }
847
+ return value;
848
+ }
849
+ function readToken(createToken, label) {
850
+ return assertIdentifier(createToken(), label);
851
+ }
852
+ function defaultTokenFactory() {
853
+ if (typeof globalThis.crypto?.randomUUID !== 'function') {
854
+ throw new DevvitPostOperationValidationError('Web Crypto randomUUID is required unless createToken is provided.');
855
+ }
856
+ return globalThis.crypto.randomUUID();
857
+ }