@ixo/oracle-runtime-workers 0.30.0 → 0.31.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.
- package/package.json +7 -5
- package/src/attachments/channel-links.ts +135 -0
- package/src/attachments/channel.ts +203 -0
- package/src/attachments/download.ts +23 -6
- package/src/attachments/index.ts +23 -0
- package/src/attachments/pipeline.ts +120 -42
- package/src/attachments/retention.ts +11 -3
- package/src/attachments/sanitize.ts +20 -8
- package/src/attachments/types.ts +7 -0
- package/src/attachments/view.ts +24 -7
- package/src/channels/contract.ts +69 -0
- package/src/channels/turns.ts +23 -1
- package/src/core/main-agent-types.ts +2 -0
- package/src/core/runtime-context.ts +3 -0
- package/src/delivery/plan.ts +3 -1
- package/src/do/contracts.ts +3 -0
- package/src/do/turn-attachments.ts +103 -0
- package/src/do/user-oracle-do.ts +142 -42
- package/src/hub-actions/client.ts +87 -7
- package/src/hub-actions/hub-text.ts +82 -0
- package/src/hub-actions/request.ts +7 -4
- package/src/hub-actions/store.ts +5 -1
- package/src/hub-actions/transfer.ts +23 -6
- package/src/index.ts +1 -0
- package/src/plugin-api/index.ts +1 -0
- package/src/plugin-api/types.ts +43 -2
- package/src/plugins/attachments/view-attachment-tool.ts +7 -3
- package/src/plugins/hub-actions/hub-actions.plugin.ts +96 -17
- package/src/plugins/ixo-chain/blocksync.ts +6 -2
- package/src/plugins/ixo-chain/http.ts +16 -8
- package/src/plugins/ixo-chain/rest.ts +7 -1
- package/src/plugins/yoma/catalog.ts +780 -0
- package/src/plugins/yoma/clef.ts +98 -0
- package/src/plugins/yoma/drafts.ts +339 -0
- package/src/plugins/yoma/engine.ts +926 -0
- package/src/plugins/yoma/index.ts +13 -0
- package/src/plugins/yoma/judge.ts +358 -0
- package/src/plugins/yoma/permission.ts +62 -0
- package/src/plugins/yoma/reads.ts +195 -0
- package/src/plugins/yoma/submission.ts +534 -0
- package/src/plugins/yoma/text.ts +16 -0
- package/src/plugins/yoma/work.ts +190 -0
- package/src/plugins/yoma/yoma.plugin.ts +2162 -0
- package/src/sqlite/serialization.ts +2 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cloudflare's Clef decision models on Workers AI: a state and typed
|
|
5
|
+
* questions in, a probability for every allowed answer out, never text.
|
|
6
|
+
* Ported from the Yoma oracle (genu-survey-oracle `src/survey/clef.ts`, itself
|
|
7
|
+
* from diagnostic-oracle). The runtime has no Workers AI binding of its own,
|
|
8
|
+
* so the host passes a runner (`YomaPluginOptions.getClef`); without one the
|
|
9
|
+
* judge falls back to option numbers and exact option text (`judge.ts`).
|
|
10
|
+
*
|
|
11
|
+
* Answer shapes as the model returned them on 2026-10-13:
|
|
12
|
+
*
|
|
13
|
+
* noul → { type: "noul", noul: 0.4531 }
|
|
14
|
+
* choice → { type: "choice", choice: "option_2",
|
|
15
|
+
* probabilities: { option_1: 0.03, ... }, confidence: 0.06 }
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const CLEF_MODEL = '@cf/cloudflare/clef';
|
|
19
|
+
|
|
20
|
+
export type ClefQuestion =
|
|
21
|
+
| { type: 'noul'; instructions: string }
|
|
22
|
+
| { type: 'choice'; instructions: string; criteria: Record<string, string> }
|
|
23
|
+
| { type: 'score'; instructions: string; criteria: string[] };
|
|
24
|
+
|
|
25
|
+
export interface ClefRequest {
|
|
26
|
+
model: 'clef' | 'clef-flash';
|
|
27
|
+
state: unknown;
|
|
28
|
+
questions: Record<string, ClefQuestion>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const probabilities = z.record(z.string(), z.number().min(0).max(1));
|
|
32
|
+
|
|
33
|
+
const noulAnswer = z.object({
|
|
34
|
+
type: z.literal('noul'),
|
|
35
|
+
noul: z.number().min(0).max(1),
|
|
36
|
+
});
|
|
37
|
+
const choiceAnswer = z.object({
|
|
38
|
+
type: z.literal('choice'),
|
|
39
|
+
choice: z.string(),
|
|
40
|
+
probabilities,
|
|
41
|
+
confidence: z.number().optional(),
|
|
42
|
+
});
|
|
43
|
+
const scoreAnswer = z.object({
|
|
44
|
+
type: z.literal('score'),
|
|
45
|
+
score: z.number(),
|
|
46
|
+
probabilities,
|
|
47
|
+
legend: z.record(z.string(), z.string()).optional(),
|
|
48
|
+
confidence: z.number().optional(),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export const clefResponseSchema = z.object({
|
|
52
|
+
model: z.string(),
|
|
53
|
+
answers: z.record(
|
|
54
|
+
z.string(),
|
|
55
|
+
z.discriminatedUnion('type', [noulAnswer, choiceAnswer, scoreAnswer]),
|
|
56
|
+
),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
export type ClefResponse = z.infer<typeof clefResponseSchema>;
|
|
60
|
+
export type ClefNoulAnswer = z.infer<typeof noulAnswer>;
|
|
61
|
+
export type ClefChoiceAnswer = z.infer<typeof choiceAnswer>;
|
|
62
|
+
|
|
63
|
+
/** One call to a Clef model. Injected, so tests never reach Workers AI. */
|
|
64
|
+
export type ClefRunner = (request: ClefRequest) => Promise<ClefResponse>;
|
|
65
|
+
|
|
66
|
+
/** The slice of a Workers AI binding a Clef runner needs. */
|
|
67
|
+
export interface WorkersAiBinding {
|
|
68
|
+
run(model: string, inputs: object): Promise<unknown>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A runner over a Workers AI binding, for hosts that have one (`env.AI`).
|
|
73
|
+
* The answer is parsed strictly: one that is not the documented shape is an
|
|
74
|
+
* error, which the judge turns into "unclear", never a guess.
|
|
75
|
+
*/
|
|
76
|
+
export function workersAiClefRunner(
|
|
77
|
+
ai: WorkersAiBinding,
|
|
78
|
+
model = CLEF_MODEL,
|
|
79
|
+
): ClefRunner {
|
|
80
|
+
return async (request) => {
|
|
81
|
+
const raw = await ai.run(model, request);
|
|
82
|
+
return clefResponseSchema.parse(raw);
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function noulOf(response: ClefResponse, id: string): ClefNoulAnswer {
|
|
87
|
+
const answer = response.answers[id];
|
|
88
|
+
if (answer?.type !== 'noul')
|
|
89
|
+
throw new Error(`Clef returned no yes/no answer for "${id}"`);
|
|
90
|
+
return answer;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function choiceOf(response: ClefResponse, id: string): ClefChoiceAnswer {
|
|
94
|
+
const answer = response.answers[id];
|
|
95
|
+
if (answer?.type !== 'choice')
|
|
96
|
+
throw new Error(`Clef returned no choice answer for "${id}"`);
|
|
97
|
+
return answer;
|
|
98
|
+
}
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { UserKvLimitError } from '../../core/user-kv';
|
|
3
|
+
import type { RuntimeContext } from '../../plugin-api/types';
|
|
4
|
+
import type { Failure } from '../ixo-chain/http';
|
|
5
|
+
import type { FormKind } from './catalog';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A person's applications and claims in progress, kept in their own database
|
|
9
|
+
* (`ctx.kv`) so they survive between messages, days and object restarts.
|
|
10
|
+
* Every answer is saved the moment it arrives.
|
|
11
|
+
*
|
|
12
|
+
* One key per draft (`draft:<form>:<collectionId>`) and a small index of
|
|
13
|
+
* the OPEN ones, so a draft never shares the 256 KiB value cap with another
|
|
14
|
+
* and one unreadable draft never takes the rest with it: each is parsed on
|
|
15
|
+
* its own, and one that fails is reported and skipped, never overwritten
|
|
16
|
+
* (it can be deleted on request). The index is advisory; a key it names
|
|
17
|
+
* that holds nothing is simply not listed.
|
|
18
|
+
*
|
|
19
|
+
* Sent work leaves the index: a sent claim is moved to the `sent` record
|
|
20
|
+
* (its claim id kept) and its draft deleted, so the next `next` starts a
|
|
21
|
+
* new claim on the same job (a permission covers as many claims as its
|
|
22
|
+
* quota); a sent application keeps its draft (the job says "applied") but
|
|
23
|
+
* no longer counts towards {@link MAX_DRAFTS}.
|
|
24
|
+
*
|
|
25
|
+
* A single-value book under the key `drafts` existed only on unreleased
|
|
26
|
+
* branches: it is ignored, never read or migrated.
|
|
27
|
+
*/
|
|
28
|
+
export const DRAFTS_NAMESPACE = 'yoma';
|
|
29
|
+
const INDEX_KEY = 'draft-index';
|
|
30
|
+
const SENT_KEY = 'sent';
|
|
31
|
+
|
|
32
|
+
/** Drafts a person can have open (not sent) at once. */
|
|
33
|
+
export const MAX_DRAFTS = 20;
|
|
34
|
+
/** Sent applications and claims remembered for `my_drafts`, newest last. */
|
|
35
|
+
export const MAX_SENT = 50;
|
|
36
|
+
/** Files kept per draft. */
|
|
37
|
+
export const MAX_EVIDENCE = 30;
|
|
38
|
+
/** Items per repeating section (each shop, each household). */
|
|
39
|
+
export const MAX_SECTION_ITEMS = 50;
|
|
40
|
+
|
|
41
|
+
const evidenceSchema = z.object({
|
|
42
|
+
/** The question it answers (`photo`, or `shops[2].photo` inside a repeating section). */
|
|
43
|
+
path: z.string().max(300),
|
|
44
|
+
url: z.string().max(2000),
|
|
45
|
+
filename: z.string().max(200),
|
|
46
|
+
mimetype: z.string().max(100),
|
|
47
|
+
size: z.number(),
|
|
48
|
+
sha256: z.string().max(64),
|
|
49
|
+
kind: z.enum(['image', 'video', 'document', 'audio']),
|
|
50
|
+
receivedAt: z.string(),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const draftSchema = z.object({
|
|
54
|
+
v: z.literal(1),
|
|
55
|
+
form: z.enum(['application', 'claim']),
|
|
56
|
+
collectionId: z.string(),
|
|
57
|
+
entityDid: z.string(),
|
|
58
|
+
protocolDid: z.string(),
|
|
59
|
+
admin: z.string(),
|
|
60
|
+
/** The job's name as the person saw it. */
|
|
61
|
+
title: z.string(),
|
|
62
|
+
startedAt: z.string(),
|
|
63
|
+
updatedAt: z.string(),
|
|
64
|
+
/** Answers in SurveyJS's data shape. File questions hold a placeholder until submit. */
|
|
65
|
+
data: z.record(z.string(), z.unknown()),
|
|
66
|
+
skipped: z.array(z.string()),
|
|
67
|
+
closedSections: z.array(z.string()),
|
|
68
|
+
evidence: z.array(evidenceSchema).max(MAX_EVIDENCE),
|
|
69
|
+
/** Sends tried so far (cleared cards and failed sends): part of the next card's request key. */
|
|
70
|
+
cardAttempt: z.number().int().min(0).default(0),
|
|
71
|
+
/** The card waiting for the person: its Hub action id, when it expires (unix s), the answers it carried, the last outcome seen. */
|
|
72
|
+
card: z
|
|
73
|
+
.object({
|
|
74
|
+
actionId: z.string(),
|
|
75
|
+
requestKey: z.string(),
|
|
76
|
+
sentAt: z.string(),
|
|
77
|
+
answersAt: z.string(),
|
|
78
|
+
expiresAt: z.number().optional(),
|
|
79
|
+
status: z.enum(['authorized', 'submitted', 'unknown']).optional(),
|
|
80
|
+
})
|
|
81
|
+
.optional(),
|
|
82
|
+
/**
|
|
83
|
+
* The read-back the person was shown for these answers (`updatedAt`): in
|
|
84
|
+
* which request, and which of its pages. `submit` sends only on a later
|
|
85
|
+
* request (their reply) with the same answers and every page shown.
|
|
86
|
+
*/
|
|
87
|
+
review: z
|
|
88
|
+
.object({
|
|
89
|
+
updatedAt: z.string(),
|
|
90
|
+
requestId: z.string().max(200),
|
|
91
|
+
pages: z.array(z.number().int().min(1).max(50)).max(50),
|
|
92
|
+
total: z.number().int().min(1).max(50),
|
|
93
|
+
})
|
|
94
|
+
.optional(),
|
|
95
|
+
/** Instant permission: the host's grant was started (at most once per draft), and when it failed if it did. */
|
|
96
|
+
grant: z
|
|
97
|
+
.object({ startedAt: z.string(), failedAt: z.string().optional() })
|
|
98
|
+
.optional(),
|
|
99
|
+
/** Set once the card's outcome came back as done. */
|
|
100
|
+
submittedAt: z.string().optional(),
|
|
101
|
+
/** A claim's id (its CID) from the claim card's receipt. */
|
|
102
|
+
claimId: z.string().max(200).optional(),
|
|
103
|
+
/** Why the last card stopped before it finished, from its receipt: the step and the Hub's words. */
|
|
104
|
+
cardFailure: z
|
|
105
|
+
.object({ step: z.string().max(40), message: z.string().max(200) })
|
|
106
|
+
.optional(),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const indexSchema = z.object({
|
|
110
|
+
v: z.literal(1),
|
|
111
|
+
keys: z.array(z.string()).max(MAX_DRAFTS * 2),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const sentItemSchema = z.object({
|
|
115
|
+
form: z.enum(['application', 'claim']),
|
|
116
|
+
collectionId: z.string(),
|
|
117
|
+
title: z.string(),
|
|
118
|
+
submittedAt: z.string(),
|
|
119
|
+
claimId: z.string().max(200).optional(),
|
|
120
|
+
/** The card's Hub action, so a sent card is recorded once. */
|
|
121
|
+
actionId: z.string().optional(),
|
|
122
|
+
});
|
|
123
|
+
const sentSchema = z.object({
|
|
124
|
+
v: z.literal(1),
|
|
125
|
+
items: z.array(sentItemSchema).max(MAX_SENT),
|
|
126
|
+
});
|
|
127
|
+
export type SentItem = z.infer<typeof sentItemSchema>;
|
|
128
|
+
|
|
129
|
+
/** A file the person sent for a question, held by the gateway until the claim is filed. */
|
|
130
|
+
export type Evidence = z.infer<typeof evidenceSchema>;
|
|
131
|
+
export type Draft = z.infer<typeof draftSchema>;
|
|
132
|
+
|
|
133
|
+
export type Kv = NonNullable<RuntimeContext['kv']>;
|
|
134
|
+
|
|
135
|
+
/** Open: not sent yet. Only open drafts are in the index and count towards {@link MAX_DRAFTS}. */
|
|
136
|
+
export const isOpen = (draft: Draft) => !draft.submittedAt;
|
|
137
|
+
|
|
138
|
+
export function draftKey(form: FormKind, collectionId: string): string {
|
|
139
|
+
return `draft:${form}:${collectionId}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const tooLargeFailure: Failure = {
|
|
143
|
+
reason: 'too_large',
|
|
144
|
+
detail:
|
|
145
|
+
'This is more than can be saved for one form; leave some files or items out.',
|
|
146
|
+
retryable: false,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** A full storage, as a failure; anything else is rethrown. */
|
|
150
|
+
export function storageFailure(error: unknown): Failure {
|
|
151
|
+
if (error instanceof UserKvLimitError) return tooLargeFailure;
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function readIndex(kv: Kv): Promise<string[]> {
|
|
156
|
+
const parsed = indexSchema.safeParse(
|
|
157
|
+
await kv.get(DRAFTS_NAMESPACE, INDEX_KEY),
|
|
158
|
+
);
|
|
159
|
+
return parsed.success ? parsed.data.keys : [];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export type DraftRead = { ok: true; draft: Draft } | { ok: false; key: string };
|
|
163
|
+
|
|
164
|
+
/** Every draft the index names, each parsed on its own; an unreadable one is reported, not dropped. */
|
|
165
|
+
export async function allDrafts(kv: Kv): Promise<DraftRead[]> {
|
|
166
|
+
const keys = await readIndex(kv);
|
|
167
|
+
const reads = await Promise.all(
|
|
168
|
+
keys.map(async (key): Promise<DraftRead | undefined> => {
|
|
169
|
+
const raw: unknown = await kv.get(DRAFTS_NAMESPACE, key);
|
|
170
|
+
if (raw === undefined) return undefined;
|
|
171
|
+
const parsed = draftSchema.safeParse(raw);
|
|
172
|
+
return parsed.success
|
|
173
|
+
? { ok: true, draft: parsed.data }
|
|
174
|
+
: { ok: false, key };
|
|
175
|
+
}),
|
|
176
|
+
);
|
|
177
|
+
return reads.filter((r): r is DraftRead => r !== undefined);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export type DraftLookup =
|
|
181
|
+
| { ok: true; draft: Draft | undefined }
|
|
182
|
+
| { ok: false; failure: Failure };
|
|
183
|
+
|
|
184
|
+
const UNREADABLE: Failure = {
|
|
185
|
+
reason: 'unavailable',
|
|
186
|
+
detail:
|
|
187
|
+
'This saved form could not be read; it can be deleted and started again.',
|
|
188
|
+
retryable: false,
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/** A change that would not make a valid draft: refused, the draft left as it was. */
|
|
192
|
+
const INVALID_WRITE: Failure = {
|
|
193
|
+
reason: 'unavailable',
|
|
194
|
+
detail: 'That could not be saved; everything saved before is kept.',
|
|
195
|
+
retryable: false,
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
export async function getDraft(
|
|
199
|
+
kv: Kv,
|
|
200
|
+
form: FormKind,
|
|
201
|
+
collectionId: string,
|
|
202
|
+
): Promise<DraftLookup> {
|
|
203
|
+
const raw: unknown = await kv.get(
|
|
204
|
+
DRAFTS_NAMESPACE,
|
|
205
|
+
draftKey(form, collectionId),
|
|
206
|
+
);
|
|
207
|
+
if (raw === undefined) return { ok: true, draft: undefined };
|
|
208
|
+
const parsed = draftSchema.safeParse(raw);
|
|
209
|
+
return parsed.success
|
|
210
|
+
? { ok: true, draft: parsed.data }
|
|
211
|
+
: { ok: false, failure: UNREADABLE };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export type DraftWrite =
|
|
215
|
+
| { ok: true; draft: Draft | undefined }
|
|
216
|
+
| { ok: false; failure: Failure };
|
|
217
|
+
|
|
218
|
+
/** What a change answers: the next draft, undefined to delete it, or a refusal that leaves it as it is. */
|
|
219
|
+
export type DraftChange = Draft | undefined | { refuse: Failure };
|
|
220
|
+
|
|
221
|
+
const isRefusal = (change: DraftChange): change is { refuse: Failure } =>
|
|
222
|
+
change !== undefined && 'refuse' in change;
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Changes one draft atomically: `change` runs inside the store's
|
|
226
|
+
* read-modify-write (synchronous, no side effects: the store may run it
|
|
227
|
+
* again), on the draft as it is stored at that moment, and returns the new
|
|
228
|
+
* one, undefined to delete it, or `{ refuse }` to leave it as it is. An
|
|
229
|
+
* unreadable stored draft is never overwritten (only deleted), and a change
|
|
230
|
+
* that is not a valid draft is refused. A new open draft is refused past
|
|
231
|
+
* {@link MAX_DRAFTS}; a write past the storage caps answers `too_large` and
|
|
232
|
+
* leaves the draft as it was. The index follows: open drafts in, the rest out.
|
|
233
|
+
*/
|
|
234
|
+
export async function updateDraft(
|
|
235
|
+
kv: Kv,
|
|
236
|
+
form: FormKind,
|
|
237
|
+
collectionId: string,
|
|
238
|
+
change: (current: Draft | undefined) => DraftChange,
|
|
239
|
+
): Promise<DraftWrite> {
|
|
240
|
+
const key = draftKey(form, collectionId);
|
|
241
|
+
try {
|
|
242
|
+
let refused: Failure | undefined;
|
|
243
|
+
let wasOpen = false;
|
|
244
|
+
const written: unknown = await kv.update(DRAFTS_NAMESPACE, key, (raw) => {
|
|
245
|
+
refused = undefined;
|
|
246
|
+
wasOpen = false;
|
|
247
|
+
const current =
|
|
248
|
+
raw === undefined ? undefined : draftSchema.safeParse(raw);
|
|
249
|
+
if (current?.success) wasOpen = isOpen(current.data);
|
|
250
|
+
const next = change(current?.success ? current.data : undefined);
|
|
251
|
+
if (next === undefined) return undefined;
|
|
252
|
+
if (isRefusal(next)) {
|
|
253
|
+
refused = next.refuse;
|
|
254
|
+
return raw;
|
|
255
|
+
}
|
|
256
|
+
if (current && !current.success) {
|
|
257
|
+
refused = UNREADABLE;
|
|
258
|
+
return raw;
|
|
259
|
+
}
|
|
260
|
+
const valid = draftSchema.safeParse(next);
|
|
261
|
+
if (!valid.success) {
|
|
262
|
+
refused = INVALID_WRITE;
|
|
263
|
+
return raw;
|
|
264
|
+
}
|
|
265
|
+
return valid.data;
|
|
266
|
+
});
|
|
267
|
+
if (refused) return { ok: false, failure: refused };
|
|
268
|
+
const parsed =
|
|
269
|
+
written === undefined ? undefined : draftSchema.safeParse(written);
|
|
270
|
+
const draft = parsed?.success ? parsed.data : undefined;
|
|
271
|
+
if (!draft || !isOpen(draft)) {
|
|
272
|
+
await kv.update(DRAFTS_NAMESPACE, INDEX_KEY, (raw) => {
|
|
273
|
+
const index = indexSchema.safeParse(raw);
|
|
274
|
+
const keys = index.success ? index.data.keys : [];
|
|
275
|
+
return keys.includes(key)
|
|
276
|
+
? { v: 1, keys: keys.filter((k) => k !== key) }
|
|
277
|
+
: raw;
|
|
278
|
+
});
|
|
279
|
+
return { ok: true, draft };
|
|
280
|
+
}
|
|
281
|
+
if (!wasOpen) {
|
|
282
|
+
let full = false;
|
|
283
|
+
await kv.update(DRAFTS_NAMESPACE, INDEX_KEY, (raw) => {
|
|
284
|
+
const index = indexSchema.safeParse(raw);
|
|
285
|
+
const keys = index.success ? index.data.keys : [];
|
|
286
|
+
full = false;
|
|
287
|
+
if (keys.includes(key)) return { v: 1, keys };
|
|
288
|
+
full = keys.length >= MAX_DRAFTS;
|
|
289
|
+
return full ? { v: 1, keys } : { v: 1, keys: [...keys, key] };
|
|
290
|
+
});
|
|
291
|
+
if (full) {
|
|
292
|
+
await kv.delete(DRAFTS_NAMESPACE, key);
|
|
293
|
+
return {
|
|
294
|
+
ok: false,
|
|
295
|
+
failure: {
|
|
296
|
+
reason: 'too_large',
|
|
297
|
+
detail: `They already have ${MAX_DRAFTS} forms in progress; finish or delete one first.`,
|
|
298
|
+
retryable: false,
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return { ok: true, draft };
|
|
304
|
+
} catch (error) {
|
|
305
|
+
return { ok: false, failure: storageFailure(error) };
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Sent applications and claims, oldest first; an unreadable record reads as none. */
|
|
310
|
+
export async function sentItems(kv: Kv): Promise<SentItem[]> {
|
|
311
|
+
const parsed = sentSchema.safeParse(await kv.get(DRAFTS_NAMESPACE, SENT_KEY));
|
|
312
|
+
return parsed.success ? parsed.data.items : [];
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Records a sent draft (once per card: by its action id), keeping the
|
|
317
|
+
* newest {@link MAX_SENT}. A full storage is a failure; anything else is
|
|
318
|
+
* rethrown.
|
|
319
|
+
*/
|
|
320
|
+
export async function recordSent(
|
|
321
|
+
kv: Kv,
|
|
322
|
+
item: SentItem,
|
|
323
|
+
): Promise<{ ok: true } | { ok: false; failure: Failure }> {
|
|
324
|
+
try {
|
|
325
|
+
await kv.update(DRAFTS_NAMESPACE, SENT_KEY, (raw) => {
|
|
326
|
+
const parsed = sentSchema.safeParse(raw);
|
|
327
|
+
const items = parsed.success ? parsed.data.items : [];
|
|
328
|
+
if (
|
|
329
|
+
item.actionId &&
|
|
330
|
+
items.some((existing) => existing.actionId === item.actionId)
|
|
331
|
+
)
|
|
332
|
+
return { v: 1, items };
|
|
333
|
+
return { v: 1, items: [...items, item].slice(-MAX_SENT) };
|
|
334
|
+
});
|
|
335
|
+
return { ok: true };
|
|
336
|
+
} catch (error) {
|
|
337
|
+
return { ok: false, failure: storageFailure(error) };
|
|
338
|
+
}
|
|
339
|
+
}
|