@mpgd/adapter-devvit 0.4.0 → 0.5.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,2 @@
1
+ export * from './post-operation.js';
2
+ export * from './redis-post-operation-store.js';
@@ -0,0 +1,2 @@
1
+ export * from './post-operation.js';
2
+ export * from './redis-post-operation-store.js';
@@ -0,0 +1,135 @@
1
+ declare const operationSchemaVersion: 1;
2
+ export type DevvitJsonValue = null | boolean | number | string | readonly DevvitJsonValue[] | DevvitJsonObject;
3
+ export interface DevvitJsonObject {
4
+ readonly [key: string]: DevvitJsonValue;
5
+ }
6
+ export interface DevvitDurableOperationStore {
7
+ /** Reads one durable value. Missing values return undefined. */
8
+ read(key: string): Promise<string | undefined>;
9
+ /** Atomically creates a value only when the key is absent. */
10
+ create(key: string, value: string): Promise<boolean>;
11
+ /** Atomically replaces a value only when its raw current value matches. */
12
+ compareAndSet(key: string, expectedValue: string, nextValue: string): Promise<boolean>;
13
+ /** Atomically creates an expiring reconciliation lease. */
14
+ createLease(key: string, token: string, expiresAt: Date): Promise<boolean>;
15
+ /** Deletes a lease only when the current token matches. */
16
+ releaseLease(key: string, token: string): Promise<void>;
17
+ }
18
+ export interface DevvitPostOperationScope {
19
+ readonly appScope: string;
20
+ /** Use the canonical subreddit identifier, not a display name. */
21
+ readonly subredditId: string;
22
+ }
23
+ export interface DevvitPostLaunch<TParams extends DevvitJsonObject> {
24
+ readonly entry: string;
25
+ readonly params: TParams;
26
+ }
27
+ export interface DevvitCanonicalPostData<TPayload extends DevvitJsonObject, TLaunchParams extends DevvitJsonObject> extends DevvitJsonObject {
28
+ readonly mpgd: {
29
+ readonly schemaVersion: typeof operationSchemaVersion;
30
+ readonly appScope: string;
31
+ readonly subredditId: string;
32
+ readonly operationType: string;
33
+ readonly operationId: string;
34
+ };
35
+ readonly launch: {
36
+ readonly schemaVersion: typeof operationSchemaVersion;
37
+ readonly entry: string;
38
+ readonly params: TLaunchParams;
39
+ };
40
+ readonly payload: TPayload;
41
+ }
42
+ export interface DevvitPostOperationDefinition<TPayload extends DevvitJsonObject, TLaunchParams extends DevvitJsonObject> {
43
+ readonly operationType: string;
44
+ readonly parsePayload: (input: unknown) => TPayload;
45
+ readonly parseLaunchParams: (input: unknown) => TLaunchParams;
46
+ readonly maxPostDataBytes?: number;
47
+ }
48
+ export interface DevvitPostOperationDescriptorInput<TPayload extends DevvitJsonObject, TLaunchParams extends DevvitJsonObject> {
49
+ readonly scope: DevvitPostOperationScope;
50
+ /** Must be stable across process restarts and retries. */
51
+ readonly operationId: string;
52
+ readonly payload: TPayload;
53
+ readonly launch: DevvitPostLaunch<TLaunchParams>;
54
+ }
55
+ export interface DevvitPostPublishInput<TPayload extends DevvitJsonObject, TLaunchParams extends DevvitJsonObject> {
56
+ readonly scope: DevvitPostOperationScope;
57
+ readonly operationId: string;
58
+ readonly postData: DevvitCanonicalPostData<TPayload, TLaunchParams>;
59
+ }
60
+ export interface DevvitPostPublishReceipt {
61
+ readonly postId: string;
62
+ }
63
+ export interface DevvitPostReconciliationCandidate {
64
+ readonly postId: string;
65
+ readonly postData: unknown;
66
+ }
67
+ export type DevvitPostReconciliationReason = 'submission-attempted' | 'submit-outcome-unknown' | 'invalid-publish-receipt' | 'receipt-write-failed' | 'match-not-found' | 'invalid-reconciliation-candidate' | 'reconciliation-failed';
68
+ export type DevvitPostOperationResult<TPayload extends DevvitJsonObject, TLaunchParams extends DevvitJsonObject> = {
69
+ readonly status: 'missing';
70
+ readonly operationId: string;
71
+ readonly postData: DevvitCanonicalPostData<TPayload, TLaunchParams>;
72
+ } | {
73
+ readonly status: 'prepared';
74
+ readonly operationId: string;
75
+ readonly postData: DevvitCanonicalPostData<TPayload, TLaunchParams>;
76
+ } | {
77
+ readonly status: 'created' | 'existing' | 'recovered';
78
+ readonly operationId: string;
79
+ readonly postId: string;
80
+ readonly postData: DevvitCanonicalPostData<TPayload, TLaunchParams>;
81
+ } | {
82
+ readonly status: 'reconciliation-required';
83
+ readonly operationId: string;
84
+ readonly reason: DevvitPostReconciliationReason;
85
+ readonly postData: DevvitCanonicalPostData<TPayload, TLaunchParams>;
86
+ } | {
87
+ readonly status: 'busy';
88
+ readonly operationId: string;
89
+ readonly reason: 'store-contention' | 'reconciliation-lease-held';
90
+ readonly postData: DevvitCanonicalPostData<TPayload, TLaunchParams>;
91
+ } | {
92
+ readonly status: 'conflict';
93
+ readonly operationId: string;
94
+ readonly reason: 'descriptor-mismatch';
95
+ readonly postData: DevvitCanonicalPostData<TPayload, TLaunchParams>;
96
+ } | {
97
+ readonly status: 'terminal-unresolved';
98
+ readonly operationId: string;
99
+ readonly reason: 'multiple-exact-matches' | 'published-receipt-conflict';
100
+ readonly postIds: readonly string[];
101
+ readonly postData: DevvitCanonicalPostData<TPayload, TLaunchParams>;
102
+ };
103
+ export interface ExecuteDevvitPostOperationInput<TPayload extends DevvitJsonObject, TLaunchParams extends DevvitJsonObject> extends DevvitPostOperationDescriptorInput<TPayload, TLaunchParams> {
104
+ readonly publish: (input: DevvitPostPublishInput<TPayload, TLaunchParams>) => Promise<DevvitPostPublishReceipt>;
105
+ }
106
+ export interface ReconcileDevvitPostOperationInput<TPayload extends DevvitJsonObject, TLaunchParams extends DevvitJsonObject> extends DevvitPostOperationDescriptorInput<TPayload, TLaunchParams> {
107
+ readonly findCandidates: (input: DevvitPostPublishInput<TPayload, TLaunchParams>) => Promise<readonly DevvitPostReconciliationCandidate[]>;
108
+ }
109
+ export interface DevvitPostOperationCoordinator<TPayload extends DevvitJsonObject, TLaunchParams extends DevvitJsonObject> {
110
+ execute(input: ExecuteDevvitPostOperationInput<TPayload, TLaunchParams>): Promise<DevvitPostOperationResult<TPayload, TLaunchParams>>;
111
+ read(input: DevvitPostOperationDescriptorInput<TPayload, TLaunchParams>): Promise<DevvitPostOperationResult<TPayload, TLaunchParams>>;
112
+ reconcile(input: ReconcileDevvitPostOperationInput<TPayload, TLaunchParams>): Promise<DevvitPostOperationResult<TPayload, TLaunchParams>>;
113
+ }
114
+ export interface CreateDevvitPostOperationCoordinatorOptions<TPayload extends DevvitJsonObject, TLaunchParams extends DevvitJsonObject> {
115
+ readonly definition: DevvitPostOperationDefinition<TPayload, TLaunchParams>;
116
+ readonly store: DevvitDurableOperationStore;
117
+ readonly now?: () => number;
118
+ readonly createToken?: () => string;
119
+ readonly leaseTtlMs?: number;
120
+ readonly storeAttempts?: number;
121
+ }
122
+ export declare class DevvitPostOperationValidationError extends TypeError {
123
+ constructor(message: string, options?: ErrorOptions);
124
+ }
125
+ export declare class DevvitPostOperationStateError extends Error {
126
+ constructor(message: string, options?: ErrorOptions);
127
+ }
128
+ export declare function defineDevvitPostOperation<TPayload extends DevvitJsonObject, TLaunchParams extends DevvitJsonObject>(definition: DevvitPostOperationDefinition<TPayload, TLaunchParams>): DevvitPostOperationDefinition<TPayload, TLaunchParams>;
129
+ export declare function createDevvitPostOperationCoordinator<TPayload extends DevvitJsonObject, TLaunchParams extends DevvitJsonObject>(input: CreateDevvitPostOperationCoordinatorOptions<TPayload, TLaunchParams>): DevvitPostOperationCoordinator<TPayload, TLaunchParams>;
130
+ export declare function createDevvitPostOperationKey(input: {
131
+ readonly scope: DevvitPostOperationScope;
132
+ readonly operationType: string;
133
+ readonly operationId: string;
134
+ }): string;
135
+ export {};
@@ -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
+ }
@@ -0,0 +1,23 @@
1
+ import type { DevvitDurableOperationStore } from './post-operation.js';
2
+ export interface DevvitRedisSetOptions {
3
+ readonly nx?: boolean;
4
+ readonly xx?: boolean;
5
+ readonly expiration?: Date;
6
+ }
7
+ export interface DevvitRedisTransactionLike {
8
+ multi(): Promise<void>;
9
+ discard(): Promise<unknown>;
10
+ set(key: string, value: string, options?: DevvitRedisSetOptions): Promise<unknown>;
11
+ del(...keys: readonly string[]): Promise<unknown>;
12
+ exec(): Promise<readonly unknown[] | null>;
13
+ unwatch(): Promise<unknown>;
14
+ }
15
+ export interface DevvitRedisLike {
16
+ get(key: string): Promise<string | undefined>;
17
+ set(key: string, value: string, options?: DevvitRedisSetOptions): Promise<string | undefined | null>;
18
+ watch(...keys: readonly string[]): Promise<DevvitRedisTransactionLike>;
19
+ }
20
+ export interface DevvitRedisPostOperationStoreOptions {
21
+ readonly transactionAttempts?: number;
22
+ }
23
+ export declare function createDevvitRedisPostOperationStore(redis: DevvitRedisLike, options?: DevvitRedisPostOperationStoreOptions): DevvitDurableOperationStore;
@@ -0,0 +1,110 @@
1
+ const defaultTransactionAttempts = 3;
2
+ const maximumTransactionAttempts = 32;
3
+ export function createDevvitRedisPostOperationStore(redis, options = {}) {
4
+ const transactionAttempts = normalizeTransactionAttempts(options.transactionAttempts);
5
+ return {
6
+ read(key) {
7
+ return redis.get(key);
8
+ },
9
+ async create(key, value) {
10
+ return setIfAbsent(redis, key, value);
11
+ },
12
+ async compareAndSet(key, expectedValue, nextValue) {
13
+ return mutateIfValue({
14
+ redis,
15
+ key,
16
+ expectedValue,
17
+ transactionAttempts,
18
+ queueMutation: (transaction) => transaction.set(key, nextValue),
19
+ });
20
+ },
21
+ async createLease(key, token, expiresAt) {
22
+ assertExpirationDate(expiresAt);
23
+ return setIfAbsent(redis, key, token, { expiration: expiresAt });
24
+ },
25
+ async releaseLease(key, token) {
26
+ await mutateIfValue({
27
+ redis,
28
+ key,
29
+ expectedValue: token,
30
+ transactionAttempts,
31
+ queueMutation: (transaction) => transaction.del(key),
32
+ });
33
+ },
34
+ };
35
+ }
36
+ async function setIfAbsent(redis, key, value, options = {}) {
37
+ const result = await redis.set(key, value, {
38
+ ...options,
39
+ nx: true,
40
+ });
41
+ if (result === 'OK') {
42
+ return true;
43
+ }
44
+ // Devvit Redis represents a failed SET NX with an empty/nil response.
45
+ if (result === '' || result === undefined || result === null) {
46
+ return false;
47
+ }
48
+ throw new Error(`Devvit Redis SET NX returned an unsupported response: ${result}`);
49
+ }
50
+ async function mutateIfValue(input) {
51
+ for (let attempt = 0; attempt < input.transactionAttempts; attempt += 1) {
52
+ const transaction = await input.redis.watch(input.key);
53
+ let multiStarted = false;
54
+ try {
55
+ const currentValue = await input.redis.get(input.key);
56
+ if (currentValue !== input.expectedValue) {
57
+ await transaction.unwatch();
58
+ return false;
59
+ }
60
+ await transaction.multi();
61
+ multiStarted = true;
62
+ await input.queueMutation(transaction);
63
+ const results = await transaction.exec();
64
+ if (results === null) {
65
+ continue;
66
+ }
67
+ if (!Array.isArray(results)) {
68
+ throw new Error('Devvit Redis transaction returned an unsupported response.');
69
+ }
70
+ if (results.length > 0) {
71
+ return true;
72
+ }
73
+ }
74
+ catch (error) {
75
+ await bestEffortReset(transaction, multiStarted);
76
+ throw error;
77
+ }
78
+ }
79
+ throw new Error(`Devvit Redis transaction contention exceeded ${String(input.transactionAttempts)} attempts for key: ${input.key}`);
80
+ }
81
+ async function bestEffortReset(transaction, multiStarted) {
82
+ try {
83
+ if (multiStarted) {
84
+ await transaction.discard();
85
+ }
86
+ else {
87
+ await transaction.unwatch();
88
+ }
89
+ }
90
+ catch {
91
+ // Preserve the original Redis failure; this cleanup is best-effort.
92
+ }
93
+ }
94
+ function normalizeTransactionAttempts(value) {
95
+ const transactionAttempts = value ?? defaultTransactionAttempts;
96
+ if (!Number.isSafeInteger(transactionAttempts)
97
+ || transactionAttempts < 1
98
+ || transactionAttempts > maximumTransactionAttempts) {
99
+ throw new TypeError(`transactionAttempts must be a safe integer from 1 to ${String(maximumTransactionAttempts)}.`);
100
+ }
101
+ return transactionAttempts;
102
+ }
103
+ function assertExpirationDate(value) {
104
+ if (!(value instanceof Date) || !Number.isFinite(value.getTime())) {
105
+ throw new TypeError('Devvit Redis lease expiration must be a valid Date.');
106
+ }
107
+ if (value.getTime() <= Date.now()) {
108
+ throw new TypeError('Devvit Redis lease expiration must be in the future.');
109
+ }
110
+ }
@@ -0,0 +1,17 @@
1
+ export type DevvitSurfaceMode = 'inline' | 'expanded';
2
+ export type DevvitSurfaceResult = 'inline-preview' | 'expanded-game';
3
+ export interface DevvitSurfaceClient {
4
+ getWebViewMode(): DevvitSurfaceMode;
5
+ }
6
+ export interface DevvitSurfaceOptions {
7
+ readonly client: DevvitSurfaceClient;
8
+ readonly mountInlinePreview: () => void | Promise<void>;
9
+ readonly loadExpandedGame: () => void | Promise<void>;
10
+ readonly onModeUnavailable?: (error: unknown) => void;
11
+ }
12
+ /**
13
+ * Starts the light inline surface when Devvit reports inline mode and defers the
14
+ * game bundle until an expanded surface is active. Outside a Devvit host, the
15
+ * expanded game remains available for local browser development.
16
+ */
17
+ export declare function startDevvitSurface(options: DevvitSurfaceOptions): Promise<DevvitSurfaceResult>;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Starts the light inline surface when Devvit reports inline mode and defers the
3
+ * game bundle until an expanded surface is active. Outside a Devvit host, the
4
+ * expanded game remains available for local browser development.
5
+ */
6
+ export async function startDevvitSurface(options) {
7
+ let mode;
8
+ try {
9
+ mode = options.client.getWebViewMode();
10
+ }
11
+ catch (error) {
12
+ options.onModeUnavailable?.(error);
13
+ await options.loadExpandedGame();
14
+ return 'expanded-game';
15
+ }
16
+ if (mode === 'inline') {
17
+ await options.mountInlinePreview();
18
+ return 'inline-preview';
19
+ }
20
+ await options.loadExpandedGame();
21
+ return 'expanded-game';
22
+ }
package/dist/web.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ import type { ShareResult } from '@mpgd/platform';
2
+ import { type DevvitSurfaceOptions, type DevvitSurfaceResult } from './surface.js';
3
+ export type { DevvitSurfaceClient, DevvitSurfaceMode, DevvitSurfaceOptions, DevvitSurfaceResult, } from './surface.js';
4
+ export type DevvitWebSurfaceOptions = Omit<DevvitSurfaceOptions, 'client'>;
5
+ export interface DevvitShareSheetOptions {
6
+ readonly data?: string;
7
+ readonly title?: string;
8
+ readonly text?: string;
9
+ }
10
+ export declare function startDevvitWebSurface(options: DevvitWebSurfaceOptions): Promise<DevvitSurfaceResult>;
11
+ export declare function requestDevvitExpandedMode(event: MouseEvent, entry: string): Promise<void>;
12
+ /**
13
+ * Presents Devvit's native share surface without claiming that the user
14
+ * completed sharing. Devvit does not report a completion callback.
15
+ */
16
+ export declare function presentDevvitShareSheet(options: DevvitShareSheetOptions): Promise<ShareResult>;
package/dist/web.js ADDED
@@ -0,0 +1,41 @@
1
+ import * as devvitWebClient from '@devvit/web/client';
2
+ import { startDevvitSurface, } from './surface.js';
3
+ // The Devvit package publishes browser implementations behind an export
4
+ // condition while its default declaration is a server-side panic module.
5
+ // Keep the narrow browser contract explicit until its default types expose it.
6
+ const browserClient = devvitWebClient;
7
+ export function startDevvitWebSurface(options) {
8
+ return startDevvitSurface({
9
+ ...options,
10
+ client: { getWebViewMode: browserClient.getWebViewMode },
11
+ });
12
+ }
13
+ export async function requestDevvitExpandedMode(event, entry) {
14
+ await browserClient.requestExpandedMode(event, entry);
15
+ }
16
+ /**
17
+ * Presents Devvit's native share surface without claiming that the user
18
+ * completed sharing. Devvit does not report a completion callback.
19
+ */
20
+ export async function presentDevvitShareSheet(options) {
21
+ try {
22
+ await browserClient.showShareSheet(options);
23
+ return {
24
+ status: 'shared',
25
+ completion: 'presented',
26
+ };
27
+ }
28
+ catch (error) {
29
+ if (isAbortError(error)) {
30
+ return { status: 'cancelled' };
31
+ }
32
+ return { status: 'unavailable' };
33
+ }
34
+ }
35
+ function isAbortError(error) {
36
+ return typeof DOMException !== 'undefined' && error instanceof DOMException
37
+ ? error.name === 'AbortError'
38
+ : typeof error === 'object'
39
+ && error !== null
40
+ && error.name === 'AbortError';
41
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/adapter-devvit",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Reddit Devvit Web platform adapter for mpgd games.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -28,14 +28,27 @@
28
28
  ".": {
29
29
  "types": "./dist/index.d.ts",
30
30
  "default": "./dist/index.js"
31
+ },
32
+ "./server": {
33
+ "types": "./dist/server/index.d.ts",
34
+ "default": "./dist/server/index.js"
35
+ },
36
+ "./surface": {
37
+ "types": "./dist/surface.d.ts",
38
+ "default": "./dist/surface.js"
39
+ },
40
+ "./web": {
41
+ "types": "./dist/web.d.ts",
42
+ "default": "./dist/web.js"
31
43
  }
32
44
  },
33
45
  "files": [
34
46
  "dist"
35
47
  ],
36
48
  "dependencies": {
37
- "@mpgd/bridge": "0.5.0",
38
- "@mpgd/platform": "0.4.0"
49
+ "@devvit/web": "0.13.6",
50
+ "@mpgd/platform": "0.5.0",
51
+ "@mpgd/bridge": "0.5.0"
39
52
  },
40
53
  "devDependencies": {
41
54
  "ttsc": "0.16.9",
@@ -52,6 +65,6 @@
52
65
  "lint": "ttsc --noEmit",
53
66
  "format": "ttsc format",
54
67
  "fix": "ttsc fix",
55
- "test": "vitest run"
68
+ "test": "vitest run && node test/server-dist-import.mjs && node --conditions=browser test/web-dist-import.mjs"
56
69
  }
57
70
  }