@effect-agent/storage-memory 0.1.0-beta.8 → 0.1.0-beta.80
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/dist/MemoryMessageDeliveryStore.d.mts +15 -0
- package/dist/MemoryMessageDeliveryStore.mjs +144 -0
- package/dist/MemoryMessageDeliveryStore.mjs.map +1 -0
- package/dist/MemoryScheduleStore.d.mts +10 -0
- package/dist/MemoryScheduleStore.mjs +169 -0
- package/dist/MemoryScheduleStore.mjs.map +1 -0
- package/dist/MemorySemanticIndex.d.mts +21 -0
- package/dist/MemorySemanticIndex.mjs +222 -0
- package/dist/MemorySemanticIndex.mjs.map +1 -0
- package/dist/MemorySubmissionLedger.d.mts +24 -0
- package/dist/MemorySubmissionLedger.mjs +1058 -0
- package/dist/MemorySubmissionLedger.mjs.map +1 -0
- package/dist/MemorySubscriptionStore.d.mts +9 -0
- package/dist/MemorySubscriptionStore.mjs +849 -0
- package/dist/MemorySubscriptionStore.mjs.map +1 -0
- package/dist/MemoryThreadStore.d.mts +17 -0
- package/dist/MemoryThreadStore.mjs +377 -0
- package/dist/MemoryThreadStore.mjs.map +1 -0
- package/dist/index.d.mts +7 -30
- package/dist/index.mjs +7 -1298
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/package.json +1 -45
- package/src/MemoryMessageDeliveryStore.ts +285 -0
- package/src/MemoryScheduleStore.ts +328 -0
- package/src/MemorySemanticIndex.ts +357 -0
- package/src/{memory-ledger.ts → MemorySubmissionLedger.ts} +478 -86
- package/src/MemorySubscriptionStore.ts +1549 -0
- package/src/MemoryThreadStore.ts +766 -0
- package/src/index.ts +6 -2
- package/dist/index.mjs.map +0 -1
- package/dist/testing.d.mts +0 -2
- package/dist/testing.mjs +0 -2
- package/src/memory-storage.ts +0 -614
- package/src/testing.ts +0 -10
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
//#region \0rolldown/runtime.js
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __exportAll = (all, no_symbols) => {
|
|
4
|
+
let target = {};
|
|
5
|
+
for (var name in all) __defProp(target, name, {
|
|
6
|
+
get: all[name],
|
|
7
|
+
enumerable: true
|
|
8
|
+
});
|
|
9
|
+
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
10
|
+
return target;
|
|
11
|
+
};
|
|
12
|
+
//#endregion
|
|
13
|
+
export { __exportAll as t };
|
package/package.json
CHANGED
|
@@ -1,45 +1 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@effect-agent/storage-memory",
|
|
3
|
-
"version": "0.1.0-beta.8",
|
|
4
|
-
"exports": {
|
|
5
|
-
".": {
|
|
6
|
-
"types": "./dist/index.d.mts",
|
|
7
|
-
"default": "./dist/index.mjs"
|
|
8
|
-
},
|
|
9
|
-
"./testing": {
|
|
10
|
-
"types": "./dist/testing.d.mts",
|
|
11
|
-
"default": "./dist/testing.mjs"
|
|
12
|
-
}
|
|
13
|
-
},
|
|
14
|
-
"dependencies": {
|
|
15
|
-
"@effect-agent/core": "0.1.0-beta.8",
|
|
16
|
-
"@effect-agent/session": "0.1.0-beta.8",
|
|
17
|
-
"effect": "4.0.0-beta.107"
|
|
18
|
-
},
|
|
19
|
-
"description": "In-memory Conversation store and submission ledger reference adapters for Effect Agent tests and conformance suites.",
|
|
20
|
-
"license": "MIT",
|
|
21
|
-
"repository": {
|
|
22
|
-
"type": "git",
|
|
23
|
-
"url": "git+https://github.com/danieljvdm/effect-agent.git",
|
|
24
|
-
"directory": "packages/storage-memory"
|
|
25
|
-
},
|
|
26
|
-
"files": [
|
|
27
|
-
"dist",
|
|
28
|
-
"src"
|
|
29
|
-
],
|
|
30
|
-
"type": "module",
|
|
31
|
-
"publishConfig": {
|
|
32
|
-
"access": "public"
|
|
33
|
-
},
|
|
34
|
-
"scripts": {
|
|
35
|
-
"build": "vp pack",
|
|
36
|
-
"check": "tsc --noEmit -p tsconfig.json",
|
|
37
|
-
"test": "vp test --passWithNoTests"
|
|
38
|
-
},
|
|
39
|
-
"devDependencies": {
|
|
40
|
-
"@effect/platform-node": "4.0.0-beta.107",
|
|
41
|
-
"@effect/vitest": "4.0.0-beta.107",
|
|
42
|
-
"typescript": "7.0.2",
|
|
43
|
-
"vite-plus": "0.2.6"
|
|
44
|
-
}
|
|
45
|
-
}
|
|
1
|
+
{"name":"@effect-agent/storage-memory","version":"0.1.0-beta.80","dependencies":{"@effect-agent/core":"0.1.0-beta.80","@effect-agent/thread":"0.1.0-beta.80"},"devDependencies":{"@effect-agent/engine":"0.1.0-beta.80","@effect/platform-node":"4.0.0-rc.112","@effect/vitest":"4.0.0-rc.112","effect":"4.0.0-rc.112","typescript":"7.0.2","vite-plus":"0.3.0"},"peerDependencies":{"effect":"^4.0.0-rc.112"},"exports":{".":{"types":"./dist/index.d.mts","default":"./dist/index.mjs"},"./MemoryMessageDeliveryStore":{"types":"./dist/MemoryMessageDeliveryStore.d.mts","default":"./dist/MemoryMessageDeliveryStore.mjs"},"./MemoryScheduleStore":{"types":"./dist/MemoryScheduleStore.d.mts","default":"./dist/MemoryScheduleStore.mjs"},"./MemorySemanticIndex":{"types":"./dist/MemorySemanticIndex.d.mts","default":"./dist/MemorySemanticIndex.mjs"},"./MemorySubmissionLedger":{"types":"./dist/MemorySubmissionLedger.d.mts","default":"./dist/MemorySubmissionLedger.mjs"},"./MemorySubscriptionStore":{"types":"./dist/MemorySubscriptionStore.d.mts","default":"./dist/MemorySubscriptionStore.mjs"},"./MemoryThreadStore":{"types":"./dist/MemoryThreadStore.d.mts","default":"./dist/MemoryThreadStore.mjs"}},"description":"In-memory Thread store and submission ledger reference adapters for Effect Agent tests and conformance suites.","license":"MIT","repository":{"type":"git","url":"git+https://github.com/danieljvdm/effect-agent.git","directory":"packages/storage-memory"},"files":["dist","src"],"type":"module","sideEffects":[],"publishConfig":{"access":"public"},"scripts":{"build":"vp pack","check":"tsc --noEmit -p tsconfig.json","test":"vp test --passWithNoTests"}}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { ThreadId } from "@effect-agent/core/Identifiers";
|
|
2
|
+
import {
|
|
3
|
+
applyMessageDeliveryChange,
|
|
4
|
+
defaultMessageDeliveryStoreLimits,
|
|
5
|
+
MessageDeliveryChange,
|
|
6
|
+
MessageDeliveryError,
|
|
7
|
+
MessageDeliveryFailpoint,
|
|
8
|
+
MessageDeliveryKey,
|
|
9
|
+
MessageDeliveryPageRequest,
|
|
10
|
+
MessageDeliveryRecord,
|
|
11
|
+
MessageDeliveryStore,
|
|
12
|
+
MessageDeliveryStoreLimits,
|
|
13
|
+
messageDeliveryDeadline,
|
|
14
|
+
messageDeliveryKeyString,
|
|
15
|
+
messageDeliveryUsesCapacity,
|
|
16
|
+
sameMessageDeliveryIdentity,
|
|
17
|
+
validateMessageDelivery,
|
|
18
|
+
} from "@effect-agent/thread/MessageDelivery";
|
|
19
|
+
import { ScheduleInstant } from "@effect-agent/thread/Schedule";
|
|
20
|
+
import { Effect, Layer, Ref, Schema, Semaphore } from "effect";
|
|
21
|
+
|
|
22
|
+
const codec = Schema.fromJsonString(MessageDeliveryRecord);
|
|
23
|
+
|
|
24
|
+
const decode = (text: string) =>
|
|
25
|
+
Schema.decodeEffect(codec)(text).pipe(
|
|
26
|
+
Effect.mapError(() => MessageDeliveryError.make({ reason: "corrupt", operation: "decode" })),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const Scan = Schema.Struct({
|
|
30
|
+
nowMillis: ScheduleInstant,
|
|
31
|
+
limit: Schema.Int.check(Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(100)),
|
|
32
|
+
ownerThreadId: Schema.optionalKey(ThreadId),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export interface MemoryMessageDeliveryStoreOptions {
|
|
36
|
+
/** UTF-8 bound on the complete stored record, including a processed Settlement. */
|
|
37
|
+
readonly maxStoredValueBytes?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Reference adapter. All retained rows are bounded; completed deduplication evidence is never evicted. */
|
|
41
|
+
export const memoryMessageDeliveryStoreLayer = (
|
|
42
|
+
limits: MessageDeliveryStoreLimits = defaultMessageDeliveryStoreLimits,
|
|
43
|
+
options: MemoryMessageDeliveryStoreOptions = {},
|
|
44
|
+
): Layer.Layer<MessageDeliveryStore, MessageDeliveryError> =>
|
|
45
|
+
Layer.effect(
|
|
46
|
+
MessageDeliveryStore,
|
|
47
|
+
Effect.gen(function* () {
|
|
48
|
+
const config = yield* validateMessageDelivery(MessageDeliveryStoreLimits, limits, "limits");
|
|
49
|
+
|
|
50
|
+
const maxStoredValueBytes = yield* validateMessageDelivery(
|
|
51
|
+
Schema.Int.check(Schema.isGreaterThan(0)),
|
|
52
|
+
options.maxStoredValueBytes ?? 16 * 1_024 * 1_024,
|
|
53
|
+
"stored-value-limit",
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const state = yield* Ref.make<ReadonlyMap<string, string>>(new Map());
|
|
57
|
+
const lock = yield* Semaphore.make(1);
|
|
58
|
+
const failpoint = yield* MessageDeliveryFailpoint;
|
|
59
|
+
|
|
60
|
+
const encode = Effect.fn("MemoryMessageDeliveryStore.encode")(function* (
|
|
61
|
+
record: MessageDeliveryRecord,
|
|
62
|
+
) {
|
|
63
|
+
const text = yield* Schema.encodeEffect(codec)(record).pipe(
|
|
64
|
+
Effect.mapError(() =>
|
|
65
|
+
MessageDeliveryError.make({ reason: "corrupt", operation: "encode" }),
|
|
66
|
+
),
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
if (new TextEncoder().encode(text).byteLength > maxStoredValueBytes) {
|
|
70
|
+
return yield* MessageDeliveryError.make({
|
|
71
|
+
reason: "capacity",
|
|
72
|
+
operation: "stored-value-bytes",
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return text;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const all = Effect.fn("MemoryMessageDeliveryStore.all")(function* () {
|
|
80
|
+
return yield* Effect.forEach((yield* Ref.get(state)).values(), decode);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const insert: MessageDeliveryStore["Service"]["insert"] = Effect.fn(
|
|
84
|
+
"MemoryMessageDeliveryStore.insert",
|
|
85
|
+
)(function* (record) {
|
|
86
|
+
const encoded = yield* encode(record);
|
|
87
|
+
const input = yield* decode(encoded);
|
|
88
|
+
|
|
89
|
+
if (
|
|
90
|
+
input.version !== 1 ||
|
|
91
|
+
input.status !== "pending" ||
|
|
92
|
+
input.leaseUntilMillis !== null ||
|
|
93
|
+
input.retry.attempts !== 0 ||
|
|
94
|
+
input.retry.generation !== 0 ||
|
|
95
|
+
input.retry.automaticAttempts !== 0 ||
|
|
96
|
+
input.retry.nextAttemptAtMillis !== input.createdAtMillis ||
|
|
97
|
+
input.retry.lastAttemptAtMillis !== null ||
|
|
98
|
+
input.retry.lastFailure !== null ||
|
|
99
|
+
input.deadlineAtMillis !== input.initialDeadlineAtMillis
|
|
100
|
+
) {
|
|
101
|
+
return yield* MessageDeliveryError.make({
|
|
102
|
+
reason: "validation",
|
|
103
|
+
operation: "insert-state",
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
if (
|
|
107
|
+
new TextEncoder().encode(JSON.stringify(input.envelope)).byteLength >
|
|
108
|
+
config.maxEnvelopeBytes
|
|
109
|
+
) {
|
|
110
|
+
return yield* MessageDeliveryError.make({
|
|
111
|
+
reason: "capacity",
|
|
112
|
+
operation: "envelope-bytes",
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
yield* failpoint.hit("message-delivery:insert:before");
|
|
116
|
+
|
|
117
|
+
const inserted = yield* lock.withPermit(
|
|
118
|
+
Effect.uninterruptible(
|
|
119
|
+
Effect.gen(function* () {
|
|
120
|
+
const records = yield* Ref.get(state);
|
|
121
|
+
const key = messageDeliveryKeyString(input.key);
|
|
122
|
+
const existingText = records.get(key);
|
|
123
|
+
|
|
124
|
+
if (existingText !== undefined) {
|
|
125
|
+
const existing = yield* decode(existingText);
|
|
126
|
+
|
|
127
|
+
if (!sameMessageDeliveryIdentity(existing, input))
|
|
128
|
+
return yield* MessageDeliveryError.make({
|
|
129
|
+
reason: "conflict",
|
|
130
|
+
operation: "insert",
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
return existing;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const owned = (yield* all()).filter(
|
|
137
|
+
(record) => record.key.ownerThreadId === input.key.ownerThreadId,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
if (
|
|
141
|
+
owned.length >= config.maxRetainedPerOwner ||
|
|
142
|
+
owned.filter(messageDeliveryUsesCapacity).length >= config.maxPendingPerOwner
|
|
143
|
+
) {
|
|
144
|
+
return yield* MessageDeliveryError.make({
|
|
145
|
+
reason: "capacity",
|
|
146
|
+
operation: "insert",
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
yield* Ref.set(state, new Map(records).set(key, encoded));
|
|
150
|
+
|
|
151
|
+
return input;
|
|
152
|
+
}),
|
|
153
|
+
),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
yield* failpoint.hit("message-delivery:insert:after");
|
|
157
|
+
|
|
158
|
+
return yield* decode(yield* encode(inserted));
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const get: MessageDeliveryStore["Service"]["get"] = Effect.fn(
|
|
162
|
+
"MemoryMessageDeliveryStore.get",
|
|
163
|
+
)(function* (key) {
|
|
164
|
+
const input = yield* validateMessageDelivery(MessageDeliveryKey, key, "get");
|
|
165
|
+
const text = (yield* Ref.get(state)).get(messageDeliveryKeyString(input));
|
|
166
|
+
|
|
167
|
+
return text === undefined ? null : yield* decode(text);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const change: MessageDeliveryStore["Service"]["change"] = Effect.fn(
|
|
171
|
+
"MemoryMessageDeliveryStore.change",
|
|
172
|
+
)(function* (key, change) {
|
|
173
|
+
const input = yield* validateMessageDelivery(MessageDeliveryChange, change, "change");
|
|
174
|
+
const decodedKey = yield* validateMessageDelivery(MessageDeliveryKey, key, "change");
|
|
175
|
+
const point = `message-delivery:${input._tag.toLowerCase()}`;
|
|
176
|
+
|
|
177
|
+
yield* failpoint.hit(`${point}:before`);
|
|
178
|
+
|
|
179
|
+
const changed = yield* lock.withPermit(
|
|
180
|
+
Effect.uninterruptible(
|
|
181
|
+
Effect.gen(function* () {
|
|
182
|
+
const existing = yield* get(decodedKey);
|
|
183
|
+
|
|
184
|
+
if (existing === null)
|
|
185
|
+
return yield* MessageDeliveryError.make({
|
|
186
|
+
reason: "not-found",
|
|
187
|
+
operation: "change",
|
|
188
|
+
});
|
|
189
|
+
const next = yield* Effect.fromResult(applyMessageDeliveryChange(existing, input));
|
|
190
|
+
const encoded = yield* encode(next);
|
|
191
|
+
|
|
192
|
+
yield* Ref.update(state, (records) =>
|
|
193
|
+
new Map(records).set(messageDeliveryKeyString(decodedKey), encoded),
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
return next;
|
|
197
|
+
}),
|
|
198
|
+
),
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
yield* failpoint.hit(`${point}:after`);
|
|
202
|
+
|
|
203
|
+
return yield* decode(yield* encode(changed));
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
return MessageDeliveryStore.of({
|
|
207
|
+
insert,
|
|
208
|
+
get,
|
|
209
|
+
change,
|
|
210
|
+
list: Effect.fn("MemoryMessageDeliveryStore.list")(function* (request) {
|
|
211
|
+
const input = yield* validateMessageDelivery(MessageDeliveryPageRequest, request, "list");
|
|
212
|
+
|
|
213
|
+
const records = (yield* all())
|
|
214
|
+
.filter(
|
|
215
|
+
(record) =>
|
|
216
|
+
record.key.ownerThreadId === input.ownerThreadId &&
|
|
217
|
+
(input.after === undefined || record.key.messageId > input.after),
|
|
218
|
+
)
|
|
219
|
+
.sort((left, right) =>
|
|
220
|
+
left.key.messageId < right.key.messageId
|
|
221
|
+
? -1
|
|
222
|
+
: left.key.messageId > right.key.messageId
|
|
223
|
+
? 1
|
|
224
|
+
: 0,
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const items = records.slice(0, input.limit);
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
items,
|
|
231
|
+
next: records.length > input.limit ? (items.at(-1)?.key.messageId ?? null) : null,
|
|
232
|
+
};
|
|
233
|
+
}),
|
|
234
|
+
due: Effect.fn("MemoryMessageDeliveryStore.due")(
|
|
235
|
+
function* (nowMillis, limit, ownerThreadId) {
|
|
236
|
+
const input = yield* validateMessageDelivery(
|
|
237
|
+
Scan,
|
|
238
|
+
{ nowMillis, limit, ...(ownerThreadId === undefined ? {} : { ownerThreadId }) },
|
|
239
|
+
"due",
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
return (yield* all())
|
|
243
|
+
.flatMap((record) => {
|
|
244
|
+
const deadline = messageDeliveryDeadline(record);
|
|
245
|
+
|
|
246
|
+
return deadline !== null &&
|
|
247
|
+
deadline <= input.nowMillis &&
|
|
248
|
+
(input.ownerThreadId === undefined ||
|
|
249
|
+
input.ownerThreadId === record.key.ownerThreadId)
|
|
250
|
+
? [{ key: record.key, deadline }]
|
|
251
|
+
: [];
|
|
252
|
+
})
|
|
253
|
+
.sort(
|
|
254
|
+
(left, right) =>
|
|
255
|
+
left.deadline - right.deadline ||
|
|
256
|
+
(messageDeliveryKeyString(left.key) < messageDeliveryKeyString(right.key)
|
|
257
|
+
? -1
|
|
258
|
+
: 1),
|
|
259
|
+
)
|
|
260
|
+
.slice(0, input.limit)
|
|
261
|
+
.map((record) => record.key);
|
|
262
|
+
},
|
|
263
|
+
),
|
|
264
|
+
nextDeadline: Effect.fn("MemoryMessageDeliveryStore.nextDeadline")(
|
|
265
|
+
function* (ownerThreadId) {
|
|
266
|
+
if (ownerThreadId !== undefined)
|
|
267
|
+
yield* validateMessageDelivery(ThreadId, ownerThreadId, "nextDeadline");
|
|
268
|
+
let next: number | null = null;
|
|
269
|
+
|
|
270
|
+
for (const record of yield* all()) {
|
|
271
|
+
if (ownerThreadId !== undefined && ownerThreadId !== record.key.ownerThreadId)
|
|
272
|
+
continue;
|
|
273
|
+
const deadline = messageDeliveryDeadline(record);
|
|
274
|
+
|
|
275
|
+
if (deadline !== null && (next === null || deadline < next)) next = deadline;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return next;
|
|
279
|
+
},
|
|
280
|
+
),
|
|
281
|
+
});
|
|
282
|
+
}),
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
export const MemoryMessageDeliveryStoreLive = memoryMessageDeliveryStoreLayer();
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ScheduleCapacityError,
|
|
3
|
+
ScheduleDueCursor,
|
|
4
|
+
defaultSchedulingLimits,
|
|
5
|
+
ScheduleChange,
|
|
6
|
+
ScheduleConflict,
|
|
7
|
+
ScheduleFailpoint,
|
|
8
|
+
ScheduleKey,
|
|
9
|
+
ScheduleNotFound,
|
|
10
|
+
ScheduleOwner,
|
|
11
|
+
SchedulePageRequest,
|
|
12
|
+
ScheduleRecord,
|
|
13
|
+
ScheduleStorageError,
|
|
14
|
+
ScheduleStore,
|
|
15
|
+
} from "@effect-agent/thread/Schedule";
|
|
16
|
+
import {
|
|
17
|
+
scheduleUsesCapacity,
|
|
18
|
+
applyScheduleChange,
|
|
19
|
+
compareScheduleKeys,
|
|
20
|
+
compareScheduleNames,
|
|
21
|
+
scheduleDeadline,
|
|
22
|
+
scheduleKeyString,
|
|
23
|
+
scheduleKeyOf,
|
|
24
|
+
scheduleOwnerKey,
|
|
25
|
+
} from "@effect-agent/thread/ScheduleTransition";
|
|
26
|
+
import { Effect, Layer, Ref, Result, Schema } from "effect";
|
|
27
|
+
|
|
28
|
+
interface MemoryScheduleState {
|
|
29
|
+
readonly records: ReadonlyMap<string, string>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const storageError = (operation: string, reason: "unavailable" | "corrupt") =>
|
|
33
|
+
ScheduleStorageError.make({ operation, reason });
|
|
34
|
+
|
|
35
|
+
const encodeRecord = (operation: string, record: ScheduleRecord) =>
|
|
36
|
+
Effect.try({
|
|
37
|
+
try: () => Schema.encodeSync(Schema.fromJsonString(ScheduleRecord))(record),
|
|
38
|
+
catch: () => storageError(operation, "corrupt"),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const decodeRecord = (operation: string, encoded: string) =>
|
|
42
|
+
Effect.try({
|
|
43
|
+
try: () => Schema.decodeSync(Schema.fromJsonString(ScheduleRecord))(encoded),
|
|
44
|
+
catch: () => storageError(operation, "corrupt"),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const decodeInput = <A, I>(operation: string, schema: Schema.Codec<A, I>, value: unknown) =>
|
|
48
|
+
Effect.try({
|
|
49
|
+
try: () => Schema.decodeUnknownSync(schema)(value),
|
|
50
|
+
catch: () => storageError(operation, "corrupt"),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const sameOwner = (record: ScheduleRecord, owner: ScheduleOwner): boolean =>
|
|
54
|
+
scheduleOwnerKey(record.owner) === scheduleOwnerKey(owner);
|
|
55
|
+
|
|
56
|
+
const makeScheduleStore = Effect.gen(function* () {
|
|
57
|
+
const state = yield* Ref.make<MemoryScheduleState>({ records: new Map() });
|
|
58
|
+
const failpoint = yield* ScheduleFailpoint;
|
|
59
|
+
|
|
60
|
+
const insert: ScheduleStore["Service"]["insert"] = Effect.fn("MemoryScheduleStore.insert")(
|
|
61
|
+
(record, ownerLimit) =>
|
|
62
|
+
Effect.gen(function* () {
|
|
63
|
+
const encoded = yield* encodeRecord("insert", record);
|
|
64
|
+
|
|
65
|
+
yield* failpoint.hit("schedule:insert:before");
|
|
66
|
+
|
|
67
|
+
const decision = yield* Effect.uninterruptible(
|
|
68
|
+
Ref.modify(
|
|
69
|
+
state,
|
|
70
|
+
(
|
|
71
|
+
current,
|
|
72
|
+
): readonly [
|
|
73
|
+
Result.Result<
|
|
74
|
+
ScheduleRecord,
|
|
75
|
+
ScheduleConflict | ScheduleCapacityError | ScheduleStorageError
|
|
76
|
+
>,
|
|
77
|
+
MemoryScheduleState,
|
|
78
|
+
] => {
|
|
79
|
+
const key = scheduleKeyString(record);
|
|
80
|
+
const existingText = current.records.get(key);
|
|
81
|
+
|
|
82
|
+
if (existingText !== undefined) {
|
|
83
|
+
const decoded = Result.try({
|
|
84
|
+
try: () => Schema.decodeSync(Schema.fromJsonString(ScheduleRecord))(existingText),
|
|
85
|
+
catch: () => storageError("insert", "corrupt"),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
if (Result.isFailure(decoded)) {
|
|
89
|
+
return [Result.fail(decoded.failure), current];
|
|
90
|
+
}
|
|
91
|
+
if (decoded.success.creationFingerprint === record.creationFingerprint) {
|
|
92
|
+
return [Result.succeed(decoded.success), current];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return [
|
|
96
|
+
Result.fail(
|
|
97
|
+
ScheduleConflict.make({ reason: "creation", key: scheduleKeyOf(record) }),
|
|
98
|
+
),
|
|
99
|
+
current,
|
|
100
|
+
];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let ownerCount = 0;
|
|
104
|
+
|
|
105
|
+
for (const text of current.records.values()) {
|
|
106
|
+
const decoded = Result.try({
|
|
107
|
+
try: () => Schema.decodeSync(Schema.fromJsonString(ScheduleRecord))(text),
|
|
108
|
+
catch: () => storageError("insert", "corrupt"),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
if (Result.isFailure(decoded)) {
|
|
112
|
+
return [Result.fail(decoded.failure), current];
|
|
113
|
+
}
|
|
114
|
+
if (
|
|
115
|
+
sameOwner(decoded.success, record.owner) &&
|
|
116
|
+
scheduleUsesCapacity(decoded.success)
|
|
117
|
+
)
|
|
118
|
+
ownerCount += 1;
|
|
119
|
+
}
|
|
120
|
+
if (ownerCount >= ownerLimit) {
|
|
121
|
+
return [Result.fail(ScheduleCapacityError.make({ limit: ownerLimit })), current];
|
|
122
|
+
}
|
|
123
|
+
const records = new Map(current.records);
|
|
124
|
+
|
|
125
|
+
records.set(key, encoded);
|
|
126
|
+
const next = { records };
|
|
127
|
+
|
|
128
|
+
return [Result.succeed(record), next];
|
|
129
|
+
},
|
|
130
|
+
),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const inserted = yield* Effect.fromResult(decision);
|
|
134
|
+
|
|
135
|
+
yield* failpoint.hit("schedule:insert:after");
|
|
136
|
+
|
|
137
|
+
return yield* decodeRecord("insert", yield* encodeRecord("insert", inserted));
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const get: ScheduleStore["Service"]["get"] = Effect.fn("MemoryScheduleStore.get")(
|
|
142
|
+
function* (key) {
|
|
143
|
+
const decodedKey = yield* decodeInput("get", ScheduleKey, key);
|
|
144
|
+
const text = (yield* Ref.get(state)).records.get(scheduleKeyString(decodedKey));
|
|
145
|
+
|
|
146
|
+
return text === undefined ? null : yield* decodeRecord("get", text);
|
|
147
|
+
},
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const list: ScheduleStore["Service"]["list"] = Effect.fn("MemoryScheduleStore.list")(
|
|
151
|
+
function* (request) {
|
|
152
|
+
const decodedRequest = yield* decodeInput("list", SchedulePageRequest, request);
|
|
153
|
+
const records: Array<ScheduleRecord> = [];
|
|
154
|
+
|
|
155
|
+
for (const text of (yield* Ref.get(state)).records.values()) {
|
|
156
|
+
const record = yield* decodeRecord("list", text);
|
|
157
|
+
|
|
158
|
+
if (
|
|
159
|
+
sameOwner(record, decodedRequest.owner) &&
|
|
160
|
+
(decodedRequest.after === undefined ||
|
|
161
|
+
compareScheduleNames(record.scheduleId, decodedRequest.after) > 0)
|
|
162
|
+
) {
|
|
163
|
+
records.push(record);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
records.sort((left, right) => compareScheduleNames(left.scheduleId, right.scheduleId));
|
|
167
|
+
const hasNext = records.length > decodedRequest.limit;
|
|
168
|
+
const items = records.slice(0, decodedRequest.limit);
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
items,
|
|
172
|
+
next: hasNext ? (items.at(-1)?.scheduleId ?? null) : null,
|
|
173
|
+
};
|
|
174
|
+
},
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const change: ScheduleStore["Service"]["change"] = Effect.fn("MemoryScheduleStore.change")(
|
|
178
|
+
(key, command, ownerLimit = defaultSchedulingLimits.maxSchedulesPerOwner) =>
|
|
179
|
+
Effect.gen(function* () {
|
|
180
|
+
const decodedKey = yield* decodeInput("change", ScheduleKey, key);
|
|
181
|
+
const decodedCommand = yield* decodeInput("change", ScheduleChange, command);
|
|
182
|
+
|
|
183
|
+
yield* failpoint.hit(`schedule:${decodedCommand._tag.toLowerCase()}:before`);
|
|
184
|
+
|
|
185
|
+
const decision = yield* Effect.uninterruptible(
|
|
186
|
+
Ref.modify(
|
|
187
|
+
state,
|
|
188
|
+
(
|
|
189
|
+
current,
|
|
190
|
+
): readonly [
|
|
191
|
+
Result.Result<
|
|
192
|
+
ScheduleRecord,
|
|
193
|
+
ScheduleConflict | ScheduleStorageError | ScheduleNotFound | ScheduleCapacityError
|
|
194
|
+
>,
|
|
195
|
+
MemoryScheduleState,
|
|
196
|
+
] => {
|
|
197
|
+
const storageKey = scheduleKeyString(decodedKey);
|
|
198
|
+
const text = current.records.get(storageKey);
|
|
199
|
+
|
|
200
|
+
if (text === undefined) {
|
|
201
|
+
return [Result.fail(ScheduleNotFound.make({ key: decodedKey })), current];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const decoded = Result.try({
|
|
205
|
+
try: () => Schema.decodeSync(Schema.fromJsonString(ScheduleRecord))(text),
|
|
206
|
+
catch: () => storageError("change", "corrupt"),
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
if (Result.isFailure(decoded)) {
|
|
210
|
+
return [Result.fail(decoded.failure), current];
|
|
211
|
+
}
|
|
212
|
+
const applied = applyScheduleChange(decoded.success, decodedCommand);
|
|
213
|
+
|
|
214
|
+
if (Result.isFailure(applied)) {
|
|
215
|
+
return [Result.fail(applied.failure), current];
|
|
216
|
+
}
|
|
217
|
+
if (applied.success === decoded.success) {
|
|
218
|
+
return [Result.succeed(decoded.success), current];
|
|
219
|
+
}
|
|
220
|
+
if (!scheduleUsesCapacity(decoded.success) && scheduleUsesCapacity(applied.success)) {
|
|
221
|
+
let count = 0;
|
|
222
|
+
|
|
223
|
+
for (const text of current.records.values()) {
|
|
224
|
+
const candidate = Schema.decodeUnknownResult(
|
|
225
|
+
Schema.fromJsonString(ScheduleRecord),
|
|
226
|
+
)(text);
|
|
227
|
+
|
|
228
|
+
if (Result.isFailure(candidate))
|
|
229
|
+
return [Result.fail(storageError("change", "corrupt")), current];
|
|
230
|
+
if (
|
|
231
|
+
sameOwner(candidate.success, decodedKey.owner) &&
|
|
232
|
+
scheduleUsesCapacity(candidate.success)
|
|
233
|
+
)
|
|
234
|
+
count += 1;
|
|
235
|
+
}
|
|
236
|
+
if (count >= ownerLimit)
|
|
237
|
+
return [Result.fail(ScheduleCapacityError.make({ limit: ownerLimit })), current];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const encoded = Result.try({
|
|
241
|
+
try: () =>
|
|
242
|
+
Schema.encodeSync(Schema.fromJsonString(ScheduleRecord))(applied.success),
|
|
243
|
+
catch: () => storageError("change", "corrupt"),
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
if (Result.isFailure(encoded)) {
|
|
247
|
+
return [Result.fail(encoded.failure), current];
|
|
248
|
+
}
|
|
249
|
+
const records = new Map(current.records);
|
|
250
|
+
|
|
251
|
+
records.set(storageKey, encoded.success);
|
|
252
|
+
const next = { records };
|
|
253
|
+
|
|
254
|
+
return [Result.succeed(applied.success), next];
|
|
255
|
+
},
|
|
256
|
+
),
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
const changed = yield* Effect.fromResult(decision);
|
|
260
|
+
|
|
261
|
+
yield* failpoint.hit(`schedule:${decodedCommand._tag.toLowerCase()}:after`);
|
|
262
|
+
|
|
263
|
+
return yield* decodeRecord("change", yield* encodeRecord("change", changed));
|
|
264
|
+
}),
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
const due: ScheduleStore["Service"]["due"] = Effect.fn("MemoryScheduleStore.due")(
|
|
268
|
+
function* (nowMillis, limit, owner, after) {
|
|
269
|
+
const decodedOwner =
|
|
270
|
+
owner === undefined ? undefined : yield* decodeInput("due", ScheduleOwner, owner);
|
|
271
|
+
|
|
272
|
+
const cursor =
|
|
273
|
+
after === undefined ? undefined : yield* decodeInput("due", ScheduleDueCursor, after);
|
|
274
|
+
|
|
275
|
+
const records: Array<ScheduleDueCursor> = [];
|
|
276
|
+
|
|
277
|
+
for (const text of (yield* Ref.get(state)).records.values()) {
|
|
278
|
+
const record = yield* decodeRecord("due", text);
|
|
279
|
+
const deadline = scheduleDeadline(record);
|
|
280
|
+
|
|
281
|
+
if (
|
|
282
|
+
deadline !== null &&
|
|
283
|
+
deadline <= nowMillis &&
|
|
284
|
+
(decodedOwner === undefined || sameOwner(record, decodedOwner)) &&
|
|
285
|
+
(cursor === undefined ||
|
|
286
|
+
deadline > cursor.deadlineAtMillis ||
|
|
287
|
+
(deadline === cursor.deadlineAtMillis && compareScheduleKeys(record, cursor) > 0))
|
|
288
|
+
) {
|
|
289
|
+
records.push({ ...scheduleKeyOf(record), deadlineAtMillis: deadline });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
records.sort((left, right) => {
|
|
293
|
+
const byDeadline = left.deadlineAtMillis - right.deadlineAtMillis;
|
|
294
|
+
|
|
295
|
+
return byDeadline !== 0 ? byDeadline : compareScheduleKeys(left, right);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
return records.slice(0, limit);
|
|
299
|
+
},
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
const nextDeadline: ScheduleStore["Service"]["nextDeadline"] = Effect.fn(
|
|
303
|
+
"MemoryScheduleStore.nextDeadline",
|
|
304
|
+
)(function* (owner) {
|
|
305
|
+
const decodedOwner =
|
|
306
|
+
owner === undefined ? undefined : yield* decodeInput("nextDeadline", ScheduleOwner, owner);
|
|
307
|
+
|
|
308
|
+
let earliest: number | null = null;
|
|
309
|
+
|
|
310
|
+
for (const text of (yield* Ref.get(state)).records.values()) {
|
|
311
|
+
const record = yield* decodeRecord("nextDeadline", text);
|
|
312
|
+
|
|
313
|
+
if (decodedOwner !== undefined && !sameOwner(record, decodedOwner)) continue;
|
|
314
|
+
const deadline = scheduleDeadline(record);
|
|
315
|
+
|
|
316
|
+
if (deadline !== null && (earliest === null || deadline < earliest)) earliest = deadline;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return earliest;
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
return ScheduleStore.of({ insert, get, list, change, due, nextDeadline });
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
export const memoryScheduleStoreLayer = (): Layer.Layer<ScheduleStore> =>
|
|
326
|
+
Layer.effect(ScheduleStore, makeScheduleStore);
|
|
327
|
+
|
|
328
|
+
export const MemoryScheduleStoreLive: Layer.Layer<ScheduleStore> = memoryScheduleStoreLayer();
|