slotlock 0.1.1 → 0.3.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/CHANGELOG.md +455 -0
- package/CONTRIBUTING.md +55 -19
- package/GOVERNANCE.md +4 -2
- package/MAINTAINERS.md +3 -2
- package/NOTICE +4 -4
- package/README.md +403 -103
- package/SECURITY.md +70 -5
- package/SPEC.md +161 -21
- package/agent-server.d.ts +76 -10
- package/agent-server.js +381 -146
- package/agent-store-backend.d.ts +22 -0
- package/agent-store-backend.js +598 -24
- package/api-keys.d.ts +22 -7
- package/api-keys.js +38 -8
- package/application-api.d.ts +67 -0
- package/application-api.js +552 -0
- package/config.d.ts +93 -0
- package/config.js +192 -0
- package/dashboard.d.ts +10 -1
- package/dashboard.js +23 -5
- package/ddl.d.ts +47 -4
- package/ddl.js +679 -25
- package/engine.js +20 -6
- package/event-schema.d.ts +59 -0
- package/event-schema.js +85 -0
- package/hooks.d.ts +7 -0
- package/hooks.js +13 -0
- package/ical-worker-thread.js +4 -2
- package/ical-worker.d.ts +8 -5
- package/ical-worker.js +9 -2
- package/index.d.ts +12 -8
- package/index.js +7 -5
- package/maintenance.d.ts +67 -0
- package/maintenance.js +213 -0
- package/node-server.d.ts +9 -1
- package/node-server.js +127 -6
- package/openapi.json +6024 -0
- package/package.json +3 -2
- package/rate-limit.d.ts +71 -0
- package/rate-limit.js +718 -0
- package/rest-api.d.ts +33 -18
- package/rest-api.js +141 -63
- package/rules.d.ts +19 -0
- package/rules.js +38 -7
- package/self-host.d.ts +26 -9
- package/self-host.js +427 -47
- package/slotlock-manifest.schema.json +6 -1
- package/slotlock.manifest.json +7 -2
- package/store.d.ts +359 -27
- package/store.js +2077 -296
- package/sync.d.ts +43 -2
- package/sync.js +1950 -277
- package/timezone.d.ts +77 -3
- package/timezone.js +265 -21
- package/types.d.ts +217 -10
- package/weekly-hours.js +2 -2
package/agent-store-backend.js
CHANGED
|
@@ -3,16 +3,78 @@ import { SlotlockAgentOperationError, } from './agent-server.js';
|
|
|
3
3
|
import { findNextAvailable } from './engine.js';
|
|
4
4
|
import { expandRules } from './rules.js';
|
|
5
5
|
import { calendarEventRollingHorizon } from './store.js';
|
|
6
|
+
import { maintainSlotlockTenant } from './maintenance.js';
|
|
7
|
+
import { parseAvailabilityRules } from './weekly-hours.js';
|
|
8
|
+
/**
|
|
9
|
+
* A write runs under READ COMMITTED whatever the role's default: a store given tenant limits (as
|
|
10
|
+
* `slotlock serve`'s is) counts it against them under the tenant's lock, which needs that level,
|
|
11
|
+
* and refuses it otherwise.
|
|
12
|
+
*/
|
|
13
|
+
const WRITE = { isolation: 'read committed' };
|
|
6
14
|
function assertActive(context) {
|
|
7
15
|
if (context.signal.aborted)
|
|
8
16
|
throw new SlotlockAgentOperationError('request_aborted', 503);
|
|
9
17
|
}
|
|
10
|
-
async function withTenant(store, context, callback) {
|
|
18
|
+
async function withTenant(store, context, callback, options) {
|
|
11
19
|
assertActive(context);
|
|
12
|
-
|
|
20
|
+
let value;
|
|
21
|
+
try {
|
|
22
|
+
value = await store.withTenant(context.principal.tenantRef, callback, {
|
|
23
|
+
...options,
|
|
24
|
+
signal: context.signal,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
const code = error?.code;
|
|
29
|
+
// A suspended tenant may read; a write it asks for is forbidden, not a server failure.
|
|
30
|
+
if (code === 'tenant_suspended')
|
|
31
|
+
throw new SlotlockAgentOperationError('tenant_suspended', 403);
|
|
32
|
+
// Waited past the store's lock timeout behind the tenant's own writes: retry later.
|
|
33
|
+
if (code === 'lock_timeout')
|
|
34
|
+
throw new SlotlockAgentOperationError('server_busy', 503);
|
|
35
|
+
assertActive(context);
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
13
38
|
assertActive(context);
|
|
14
39
|
return value;
|
|
15
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Busy intervals one free/busy read or search may gather across all the resources it names: as many
|
|
43
|
+
* as the store reads for one resource. Without it, a request naming 100 resources at that limit, or
|
|
44
|
+
* one resource 100 times, did 100 times the work of one.
|
|
45
|
+
*/
|
|
46
|
+
const MAX_REQUEST_BUSY_INTERVALS = 10_000;
|
|
47
|
+
/**
|
|
48
|
+
* Bookable windows one search may expand from its resources' availability rules in all: five
|
|
49
|
+
* resources of 50 daily rules over a year, or a hundred of two. Without it, a search of 100 such
|
|
50
|
+
* resources over a year expanded 1.8 million windows and held the process for seven seconds.
|
|
51
|
+
*/
|
|
52
|
+
const MAX_REQUEST_RULE_WINDOWS = 100_000;
|
|
53
|
+
/** The resources a request names, each once, in the order first named. */
|
|
54
|
+
function distinctResourceIds(input) {
|
|
55
|
+
return [...new Set(input.resource_ids)];
|
|
56
|
+
}
|
|
57
|
+
/** Counts a request's busy intervals against MAX_REQUEST_BUSY_INTERVALS, refusing past it. */
|
|
58
|
+
function busyBudget() {
|
|
59
|
+
let left = MAX_REQUEST_BUSY_INTERVALS;
|
|
60
|
+
return (intervals) => {
|
|
61
|
+
left -= intervals;
|
|
62
|
+
if (left < 0)
|
|
63
|
+
throw new SlotlockAgentOperationError('calendar_work_limit', 400);
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/** The store's free/busy read, its refusal to read past its limit answered as the caller's to narrow. */
|
|
67
|
+
async function readFreeBusy(tenantStore, params) {
|
|
68
|
+
try {
|
|
69
|
+
return await tenantStore.getFreeBusy(params);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (error?.code === 'calendar_work_limit') {
|
|
73
|
+
throw new SlotlockAgentOperationError('calendar_work_limit', 400);
|
|
74
|
+
}
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
16
78
|
function interval(input) {
|
|
17
79
|
return { start: new Date(input.start), end: new Date(input.end) };
|
|
18
80
|
}
|
|
@@ -177,25 +239,41 @@ function parseEventCursor(value) {
|
|
|
177
239
|
throw new SlotlockAgentOperationError('invalid_cursor', 400);
|
|
178
240
|
}
|
|
179
241
|
}
|
|
180
|
-
|
|
181
|
-
|
|
242
|
+
/** The cursor after one row of a page ordered by start then id: an event, a hold or a booking. */
|
|
243
|
+
function encodeEventCursor(row) {
|
|
244
|
+
return Buffer.from(JSON.stringify({ start: row.start.toISOString(), id: row.id }), 'utf8').toString('base64url');
|
|
245
|
+
}
|
|
246
|
+
/** One page of rows read with one more than `limit`, and the cursor to the next when there is one. */
|
|
247
|
+
function page(rows, limit) {
|
|
248
|
+
const shown = rows.slice(0, limit);
|
|
249
|
+
const last = shown.at(-1);
|
|
250
|
+
return { shown, next: rows.length > limit && last ? encodeEventCursor(last) : null };
|
|
182
251
|
}
|
|
183
252
|
/**
|
|
184
253
|
* The caller's resource with this id, or `resource_not_found`. The tools accept any id an agent may
|
|
185
254
|
* send (an external reference such as `vehicle-42`, the nil UUID, a version 7 UUID). The store
|
|
186
255
|
* issues version 4 UUIDs (gen_random_uuid) and refuses an id that is not a version 1 to 5 UUID as an
|
|
187
256
|
* invalid identity, so such an id names no resource: it is not found, like a well-formed id that
|
|
188
|
-
* names nothing or another tenant's resource.
|
|
257
|
+
* names nothing or another tenant's resource. With `visibility`, a resource of the other is not
|
|
258
|
+
* found either: agents ask for `shared`, so a private resource is invisible to them.
|
|
189
259
|
*/
|
|
190
|
-
async function requireResource(tenantStore, tenantRef, resourceId) {
|
|
260
|
+
async function requireResource(tenantStore, tenantRef, resourceId, visibility) {
|
|
191
261
|
if (!STORE_ID_PATTERN.test(resourceId)) {
|
|
192
262
|
throw new SlotlockAgentOperationError('resource_not_found', 404);
|
|
193
263
|
}
|
|
194
|
-
const resource = await tenantStore.getResource({
|
|
264
|
+
const resource = await tenantStore.getResource({
|
|
265
|
+
tenantRef,
|
|
266
|
+
id: resourceId,
|
|
267
|
+
...(visibility !== undefined ? { visibility } : {}),
|
|
268
|
+
});
|
|
195
269
|
if (!resource)
|
|
196
270
|
throw new SlotlockAgentOperationError('resource_not_found', 404);
|
|
197
271
|
return resource;
|
|
198
272
|
}
|
|
273
|
+
/** The caller's shared resource with this id: what an agent may see and book. */
|
|
274
|
+
function requireAgentResource(tenantStore, tenantRef, resourceId) {
|
|
275
|
+
return requireResource(tenantStore, tenantRef, resourceId, 'shared');
|
|
276
|
+
}
|
|
199
277
|
/** Bind the protocol server to the production store without giving a request control of tenancy. */
|
|
200
278
|
export function createSlotlockStoreAgentBackend(store, options) {
|
|
201
279
|
return {
|
|
@@ -206,6 +284,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
206
284
|
const resource = await tenantStore.getResource({
|
|
207
285
|
tenantRef: context.principal.tenantRef,
|
|
208
286
|
id: resourceId,
|
|
287
|
+
visibility: 'shared',
|
|
209
288
|
});
|
|
210
289
|
return resource
|
|
211
290
|
? { name: resource.externalRef ?? null, timezone: resource.timezone }
|
|
@@ -218,6 +297,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
218
297
|
const limit = input.limit;
|
|
219
298
|
const resources = await tenantStore.listResources({
|
|
220
299
|
tenantRef: context.principal.tenantRef,
|
|
300
|
+
visibility: 'shared',
|
|
221
301
|
limit: limit + 1,
|
|
222
302
|
...(cursor !== undefined ? { after: cursor } : {}),
|
|
223
303
|
});
|
|
@@ -234,13 +314,14 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
234
314
|
},
|
|
235
315
|
async getFreeBusy(context, input) {
|
|
236
316
|
const window = interval(input);
|
|
237
|
-
const resourceIds = input
|
|
317
|
+
const resourceIds = distinctResourceIds(input);
|
|
318
|
+
const charge = busyBudget();
|
|
238
319
|
return withTenant(store, context, async (tenantStore) => {
|
|
239
320
|
const resources = [];
|
|
240
321
|
for (const resourceId of resourceIds) {
|
|
241
|
-
const resource = await
|
|
322
|
+
const resource = await requireAgentResource(tenantStore, context.principal.tenantRef, resourceId);
|
|
242
323
|
const sources = await requiredSources(options, context, resource);
|
|
243
|
-
const result = await tenantStore
|
|
324
|
+
const result = await readFreeBusy(tenantStore, {
|
|
244
325
|
tenantRef: context.principal.tenantRef,
|
|
245
326
|
resourceId,
|
|
246
327
|
window,
|
|
@@ -249,6 +330,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
249
330
|
? { coverageMaxAgeMs: options.coverageMaxAgeMs }
|
|
250
331
|
: {}),
|
|
251
332
|
});
|
|
333
|
+
charge(result.busy.length);
|
|
252
334
|
resources.push({
|
|
253
335
|
resource_id: resourceId,
|
|
254
336
|
busy: result.busy.map((busy) => ({
|
|
@@ -263,15 +345,17 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
263
345
|
},
|
|
264
346
|
async findNextAvailable(context, input) {
|
|
265
347
|
const window = interval(input);
|
|
266
|
-
const resourceIds = input
|
|
348
|
+
const resourceIds = distinctResourceIds(input);
|
|
267
349
|
const durationMs = input.duration_minutes * 60_000;
|
|
350
|
+
const charge = busyBudget();
|
|
351
|
+
let windowsLeft = MAX_REQUEST_RULE_WINDOWS;
|
|
268
352
|
return withTenant(store, context, async (tenantStore) => {
|
|
269
353
|
let best = null;
|
|
270
354
|
let incomplete = false;
|
|
271
355
|
for (const resourceId of resourceIds) {
|
|
272
|
-
const resource = await
|
|
356
|
+
const resource = await requireAgentResource(tenantStore, context.principal.tenantRef, resourceId);
|
|
273
357
|
const sources = await requiredSources(options, context, resource);
|
|
274
|
-
const freeBusy = await tenantStore
|
|
358
|
+
const freeBusy = await readFreeBusy(tenantStore, {
|
|
275
359
|
tenantRef: context.principal.tenantRef,
|
|
276
360
|
resourceId,
|
|
277
361
|
window,
|
|
@@ -280,16 +364,17 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
280
364
|
? { coverageMaxAgeMs: options.coverageMaxAgeMs }
|
|
281
365
|
: {}),
|
|
282
366
|
});
|
|
367
|
+
charge(freeBusy.busy.length);
|
|
283
368
|
if (freeBusy.coverage.state !== 'complete') {
|
|
284
369
|
incomplete = true;
|
|
285
370
|
continue;
|
|
286
371
|
}
|
|
287
372
|
const rules = resource.availabilityRules ?? (await options.availabilityRules(context, resource));
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
});
|
|
373
|
+
const windows = expandRules(rules, window, resource.timezone);
|
|
374
|
+
windowsLeft -= windows.length;
|
|
375
|
+
if (windowsLeft < 0)
|
|
376
|
+
throw new SlotlockAgentOperationError('calendar_work_limit', 400);
|
|
377
|
+
const candidate = findNextAvailable({ busy: freeBusy.busy, windows, durationMs });
|
|
293
378
|
if (candidate &&
|
|
294
379
|
(!best ||
|
|
295
380
|
candidate.start.getTime() < best.interval.start.getTime() ||
|
|
@@ -313,7 +398,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
313
398
|
async createEvent(context, rawInput) {
|
|
314
399
|
const input = rawInput;
|
|
315
400
|
return withTenant(store, context, async (tenantStore) => {
|
|
316
|
-
await
|
|
401
|
+
await requireAgentResource(tenantStore, context.principal.tenantRef, input.resource_id);
|
|
317
402
|
const recurrence = recurrenceFromInput(input.recurrence_rule, input.recurrence_exceptions);
|
|
318
403
|
const content = {
|
|
319
404
|
start: new Date(input.starts_at),
|
|
@@ -357,7 +442,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
357
442
|
if (!created)
|
|
358
443
|
throw new SlotlockAgentOperationError('store_inconsistent', 503);
|
|
359
444
|
return { event: projectSlotlockCalendarEvent(created), replayed: result.idempotent };
|
|
360
|
-
});
|
|
445
|
+
}, WRITE);
|
|
361
446
|
},
|
|
362
447
|
async getEvent(context, input) {
|
|
363
448
|
return withTenant(store, context, async (tenantStore) => {
|
|
@@ -373,13 +458,13 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
373
458
|
},
|
|
374
459
|
async listEvents(context, input) {
|
|
375
460
|
const window = interval(input);
|
|
376
|
-
const resourceIds = input
|
|
461
|
+
const resourceIds = distinctResourceIds(input);
|
|
377
462
|
const limit = input.limit;
|
|
378
463
|
const after = parseEventCursor(input.cursor);
|
|
379
464
|
return withTenant(store, context, async (tenantStore) => {
|
|
380
465
|
const combined = [];
|
|
381
466
|
for (const resourceId of resourceIds) {
|
|
382
|
-
await
|
|
467
|
+
await requireAgentResource(tenantStore, context.principal.tenantRef, resourceId);
|
|
383
468
|
combined.push(...(await tenantStore.listCalendarEvents({
|
|
384
469
|
tenantRef: context.principal.tenantRef,
|
|
385
470
|
ownerRef: agentOwnerRef(context),
|
|
@@ -400,6 +485,11 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
400
485
|
},
|
|
401
486
|
async updateEvent(context, rawInput) {
|
|
402
487
|
const input = rawInput;
|
|
488
|
+
// No event is on a resource the store never issued (an external reference, say).
|
|
489
|
+
if (input.expected_resource_id !== undefined &&
|
|
490
|
+
!STORE_ID_PATTERN.test(input.expected_resource_id)) {
|
|
491
|
+
throw new SlotlockAgentOperationError('event_not_found', 404);
|
|
492
|
+
}
|
|
403
493
|
return withTenant(store, context, async (tenantStore) => {
|
|
404
494
|
const current = await tenantStore.getCalendarEvent({
|
|
405
495
|
tenantRef: context.principal.tenantRef,
|
|
@@ -410,7 +500,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
410
500
|
throw new SlotlockAgentOperationError('event_not_found', 404);
|
|
411
501
|
const resourceId = input.resource_id ?? current.resourceId;
|
|
412
502
|
if (resourceId !== current.resourceId) {
|
|
413
|
-
await
|
|
503
|
+
await requireAgentResource(tenantStore, context.principal.tenantRef, resourceId);
|
|
414
504
|
}
|
|
415
505
|
const recurrence = input.recurrence_rule === null
|
|
416
506
|
? undefined
|
|
@@ -469,6 +559,9 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
469
559
|
tenantRef: context.principal.tenantRef,
|
|
470
560
|
ownerRef: agentOwnerRef(context),
|
|
471
561
|
externalRef: current.externalRef,
|
|
562
|
+
// The caller's fence when it sent one, checked under the event's lock; otherwise the
|
|
563
|
+
// resource just read, so a move between that read and the write is not overwritten.
|
|
564
|
+
expectedResourceId: input.expected_resource_id ?? current.resourceId,
|
|
472
565
|
resourceId,
|
|
473
566
|
expectedRevision: input.expected_revision,
|
|
474
567
|
idempotencyKey: agentCommandIdempotencyKey(context, input.idempotency_key),
|
|
@@ -486,7 +579,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
486
579
|
if (!updated)
|
|
487
580
|
throw new SlotlockAgentOperationError('store_inconsistent', 503);
|
|
488
581
|
return { event: projectSlotlockCalendarEvent(updated), replayed: result.idempotent };
|
|
489
|
-
});
|
|
582
|
+
}, WRITE);
|
|
490
583
|
},
|
|
491
584
|
async deleteEvent(context, input) {
|
|
492
585
|
return withTenant(store, context, async (tenantStore) => {
|
|
@@ -502,6 +595,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
502
595
|
tenantRef: context.principal.tenantRef,
|
|
503
596
|
ownerRef: agentOwnerRef(context),
|
|
504
597
|
externalRef,
|
|
598
|
+
expectedResourceId: current.resourceId,
|
|
505
599
|
idempotencyKey: agentCommandIdempotencyKey(context, input.idempotency_key),
|
|
506
600
|
expectedRevision: input.expected_revision,
|
|
507
601
|
});
|
|
@@ -513,7 +607,487 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
513
607
|
deleted: true,
|
|
514
608
|
replayed: result.idempotent,
|
|
515
609
|
};
|
|
610
|
+
}, WRITE);
|
|
611
|
+
},
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
/** A store failure thrown with a `code` the caller may see, or the error itself to report. */
|
|
615
|
+
function storeRefusal(error) {
|
|
616
|
+
const code = error?.code;
|
|
617
|
+
if (typeof code !== 'string')
|
|
618
|
+
return error;
|
|
619
|
+
if (code === 'resource_limit_reached' || code === 'resource_visibility_conflict') {
|
|
620
|
+
return new SlotlockAgentOperationError(code, 409);
|
|
621
|
+
}
|
|
622
|
+
if (code.startsWith('invalid_') && code !== 'invalid_transaction_isolation') {
|
|
623
|
+
return new SlotlockAgentOperationError(code, 400);
|
|
624
|
+
}
|
|
625
|
+
return error;
|
|
626
|
+
}
|
|
627
|
+
/** The status an application refusal answers with; the resource of another tenant is not found. */
|
|
628
|
+
function applicationRefusal(code) {
|
|
629
|
+
if (code === 'resource_tenant_mismatch') {
|
|
630
|
+
return new SlotlockAgentOperationError('resource_not_found', 404);
|
|
631
|
+
}
|
|
632
|
+
if (code.endsWith('_not_found'))
|
|
633
|
+
return new SlotlockAgentOperationError(code, 404);
|
|
634
|
+
if (code.startsWith('invalid_'))
|
|
635
|
+
return new SlotlockAgentOperationError(code, 400);
|
|
636
|
+
return new SlotlockAgentOperationError(code, 409);
|
|
637
|
+
}
|
|
638
|
+
function rulesOutput(rules) {
|
|
639
|
+
if (!rules)
|
|
640
|
+
return null;
|
|
641
|
+
return rules.map((rule) => ({
|
|
642
|
+
rrule: rule.rrule,
|
|
643
|
+
start_minutes: rule.startMinutes,
|
|
644
|
+
duration_minutes: rule.durationMinutes,
|
|
645
|
+
}));
|
|
646
|
+
}
|
|
647
|
+
function rulesInput(value) {
|
|
648
|
+
if (value === null)
|
|
649
|
+
return null;
|
|
650
|
+
const rules = value.map((rule) => ({
|
|
651
|
+
rrule: rule.rrule,
|
|
652
|
+
startMinutes: rule.start_minutes,
|
|
653
|
+
durationMinutes: rule.duration_minutes,
|
|
654
|
+
}));
|
|
655
|
+
try {
|
|
656
|
+
return parseAvailabilityRules(rules);
|
|
657
|
+
}
|
|
658
|
+
catch (error) {
|
|
659
|
+
throw storeRefusal(error);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
function resourceOutput(resource) {
|
|
663
|
+
return {
|
|
664
|
+
id: resource.id,
|
|
665
|
+
external_ref: resource.externalRef,
|
|
666
|
+
timezone: resource.timezone,
|
|
667
|
+
availability: rulesOutput(resource.availabilityRules),
|
|
668
|
+
visibility: resource.visibility ?? 'shared',
|
|
669
|
+
archived_at: resource.archivedAt?.toISOString() ?? null,
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
/** Any of a tenant's events: the store's id, which the tenant-event routes take, and its owner's. */
|
|
673
|
+
function tenantEventOutput(event) {
|
|
674
|
+
return {
|
|
675
|
+
...projectSlotlockCalendarEvent(event),
|
|
676
|
+
id: event.id,
|
|
677
|
+
event_ref: event.externalRef,
|
|
678
|
+
owner_ref: event.ownerRef,
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
function holdOutput(hold) {
|
|
682
|
+
return {
|
|
683
|
+
id: hold.id,
|
|
684
|
+
resource_id: hold.resourceId,
|
|
685
|
+
start: hold.start.toISOString(),
|
|
686
|
+
end: hold.end.toISOString(),
|
|
687
|
+
expires_at: hold.expiresAt.toISOString(),
|
|
688
|
+
source: hold.source,
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
function reservationOutput(reservation) {
|
|
692
|
+
return {
|
|
693
|
+
id: reservation.id,
|
|
694
|
+
resource_id: reservation.resourceId,
|
|
695
|
+
reservation_ref: reservation.externalRef,
|
|
696
|
+
start: reservation.start.toISOString(),
|
|
697
|
+
end: reservation.end.toISOString(),
|
|
698
|
+
// The store keeps milliseconds; a buffer set in seconds through the library still shows as busy.
|
|
699
|
+
buffer_after_minutes: Math.ceil(reservation.bufferAfterMs / 60_000),
|
|
700
|
+
revision: reservation.revision,
|
|
701
|
+
source: reservation.source,
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* The namespace an application caller's idempotency keys live in: a digest of its tenant and
|
|
706
|
+
* subject, so the authenticated subject never lands in a durable row.
|
|
707
|
+
*/
|
|
708
|
+
function applicationPrincipalRef(context) {
|
|
709
|
+
return `application:${createHash('sha256')
|
|
710
|
+
.update(`${context.principal.tenantRef}\0${context.principal.subject}`)
|
|
711
|
+
.digest('hex')}`;
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* An application caller's event command key: its tenant, subject and own key, digested, so one
|
|
715
|
+
* caller's key never replays another's command and neither the subject nor the raw key is stored.
|
|
716
|
+
*/
|
|
717
|
+
function applicationCommandKey(context, idempotencyKey) {
|
|
718
|
+
return `application-command:${createHash('sha256')
|
|
719
|
+
.update(`${context.principal.tenantRef}\0${context.principal.subject}\0${idempotencyKey}`)
|
|
720
|
+
.digest('hex')}`;
|
|
721
|
+
}
|
|
722
|
+
/** The most busy intervals one slotlock_list_busy answers, as one free/busy request reads. */
|
|
723
|
+
const MAX_LISTED_BUSY = MAX_REQUEST_BUSY_INTERVALS;
|
|
724
|
+
/**
|
|
725
|
+
* Serve the application API (application-api.ts) from the production store, in the principal's
|
|
726
|
+
* tenant. Every resource an operation names must be the tenant's, or it is not found; holds are
|
|
727
|
+
* reservations under the tenant's row-level security, so another tenant's hold is not found either.
|
|
728
|
+
*/
|
|
729
|
+
export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
730
|
+
const max = options.maxTenantResources;
|
|
731
|
+
if (max !== undefined && !(Number.isSafeInteger(max) && max >= 1 && max <= 100_000)) {
|
|
732
|
+
throw new RangeError('maxTenantResources must be a whole number from 1 to 100,000');
|
|
733
|
+
}
|
|
734
|
+
const batches = options.maintenanceBatches ?? 10;
|
|
735
|
+
if (!(Number.isSafeInteger(batches) && batches >= 1 && batches <= 100)) {
|
|
736
|
+
throw new RangeError('maintenanceBatches must be a whole number from 1 to 100');
|
|
737
|
+
}
|
|
738
|
+
const tenantErasure = options.tenantErasure === true;
|
|
739
|
+
const tenantOf = (context) => context.principal.tenantRef;
|
|
740
|
+
return {
|
|
741
|
+
async createResource(context, input) {
|
|
742
|
+
// A capped create counts under READ COMMITTED; withTenant opens it for the call.
|
|
743
|
+
return withTenant(store, context, async (tenant) => {
|
|
744
|
+
try {
|
|
745
|
+
const resource = await tenant.createResource({
|
|
746
|
+
tenantRef: tenantOf(context),
|
|
747
|
+
externalRef: input.external_ref,
|
|
748
|
+
timezone: input.timezone,
|
|
749
|
+
...(input.visibility !== undefined
|
|
750
|
+
? { visibility: input.visibility }
|
|
751
|
+
: {}),
|
|
752
|
+
...(max !== undefined ? { maxTenantResources: max } : {}),
|
|
753
|
+
});
|
|
754
|
+
return { resource: resourceOutput(resource) };
|
|
755
|
+
}
|
|
756
|
+
catch (error) {
|
|
757
|
+
throw storeRefusal(error);
|
|
758
|
+
}
|
|
759
|
+
}, max !== undefined ? { isolation: 'read committed' } : undefined);
|
|
760
|
+
},
|
|
761
|
+
async getResource(context, input) {
|
|
762
|
+
return withTenant(store, context, async (tenant) => ({
|
|
763
|
+
resource: resourceOutput(await requireResource(tenant, tenantOf(context), input.resource_id)),
|
|
764
|
+
}));
|
|
765
|
+
},
|
|
766
|
+
async archiveResource(context, input) {
|
|
767
|
+
const id = input.resource_id;
|
|
768
|
+
if (!STORE_ID_PATTERN.test(id))
|
|
769
|
+
throw new SlotlockAgentOperationError('resource_not_found', 404);
|
|
770
|
+
return withTenant(store, context, async (tenant) => {
|
|
771
|
+
const resource = await tenant.archiveResource({ tenantRef: tenantOf(context), id });
|
|
772
|
+
if (!resource)
|
|
773
|
+
throw new SlotlockAgentOperationError('resource_not_found', 404);
|
|
774
|
+
return { resource: resourceOutput(resource) };
|
|
775
|
+
}, WRITE);
|
|
776
|
+
},
|
|
777
|
+
async restoreResource(context, input) {
|
|
778
|
+
const id = input.resource_id;
|
|
779
|
+
if (!STORE_ID_PATTERN.test(id))
|
|
780
|
+
throw new SlotlockAgentOperationError('resource_not_found', 404);
|
|
781
|
+
return withTenant(store, context, async (tenant) => {
|
|
782
|
+
const resource = await tenant.restoreResource({ tenantRef: tenantOf(context), id });
|
|
783
|
+
if (!resource)
|
|
784
|
+
throw new SlotlockAgentOperationError('resource_not_found', 404);
|
|
785
|
+
return { resource: resourceOutput(resource) };
|
|
786
|
+
}, WRITE);
|
|
787
|
+
},
|
|
788
|
+
async deleteResource(context, input) {
|
|
789
|
+
const id = input.resource_id;
|
|
790
|
+
// An id the store never issues names no resource: there is nothing to delete.
|
|
791
|
+
if (!STORE_ID_PATTERN.test(id))
|
|
792
|
+
return { deleted: false };
|
|
793
|
+
return withTenant(store, context, async (tenant) => {
|
|
794
|
+
const result = await tenant.deleteResource({ tenantRef: tenantOf(context), id });
|
|
795
|
+
if (!result.ok)
|
|
796
|
+
throw applicationRefusal(result.code);
|
|
797
|
+
return { deleted: result.deleted };
|
|
798
|
+
}, WRITE);
|
|
799
|
+
},
|
|
800
|
+
async listBusy(context, input) {
|
|
801
|
+
return withTenant(store, context, async (tenant) => {
|
|
802
|
+
const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
|
|
803
|
+
// One row past the cap is enough to refuse: the rest is never read.
|
|
804
|
+
const busy = await tenant.listBusy(resource.id, interval(input), {
|
|
805
|
+
tenantRef: tenantOf(context),
|
|
806
|
+
limit: MAX_LISTED_BUSY + 1,
|
|
807
|
+
});
|
|
808
|
+
if (busy.length > MAX_LISTED_BUSY) {
|
|
809
|
+
throw new SlotlockAgentOperationError('calendar_work_limit', 400);
|
|
810
|
+
}
|
|
811
|
+
return {
|
|
812
|
+
busy: busy.map((occupied) => ({
|
|
813
|
+
start: occupied.start.toISOString(),
|
|
814
|
+
end: occupied.end.toISOString(),
|
|
815
|
+
})),
|
|
816
|
+
};
|
|
516
817
|
});
|
|
517
818
|
},
|
|
819
|
+
async setResourceAvailability(context, input) {
|
|
820
|
+
const rules = rulesInput(input.rules);
|
|
821
|
+
const id = input.resource_id;
|
|
822
|
+
if (!STORE_ID_PATTERN.test(id))
|
|
823
|
+
throw new SlotlockAgentOperationError('resource_not_found', 404);
|
|
824
|
+
return withTenant(store, context, async (tenant) => {
|
|
825
|
+
let resource;
|
|
826
|
+
try {
|
|
827
|
+
resource = await tenant.setResourceAvailability({ tenantRef: tenantOf(context), id, rules });
|
|
828
|
+
}
|
|
829
|
+
catch (error) {
|
|
830
|
+
throw storeRefusal(error);
|
|
831
|
+
}
|
|
832
|
+
if (!resource)
|
|
833
|
+
throw new SlotlockAgentOperationError('resource_not_found', 404);
|
|
834
|
+
return { resource: resourceOutput(resource) };
|
|
835
|
+
});
|
|
836
|
+
},
|
|
837
|
+
async setTenantAvailability(context, input) {
|
|
838
|
+
const rules = rulesInput(input.rules);
|
|
839
|
+
return withTenant(store, context, async (tenant) => {
|
|
840
|
+
try {
|
|
841
|
+
return {
|
|
842
|
+
resources_updated: await tenant.setTenantAvailability({
|
|
843
|
+
tenantRef: tenantOf(context),
|
|
844
|
+
rules,
|
|
845
|
+
}),
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
catch (error) {
|
|
849
|
+
throw storeRefusal(error);
|
|
850
|
+
}
|
|
851
|
+
});
|
|
852
|
+
},
|
|
853
|
+
async acquireHold(context, input) {
|
|
854
|
+
return withTenant(store, context, async (tenant) => {
|
|
855
|
+
const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
|
|
856
|
+
const result = await tenant.acquireHold({
|
|
857
|
+
tenantRef: tenantOf(context),
|
|
858
|
+
resourceId: resource.id,
|
|
859
|
+
start: new Date(input.start),
|
|
860
|
+
end: new Date(input.end),
|
|
861
|
+
ttlMs: input.ttl_seconds * 1_000,
|
|
862
|
+
...(input.source !== undefined ? { source: input.source } : {}),
|
|
863
|
+
...(input.buffer_after_minutes !== undefined
|
|
864
|
+
? { bufferAfterMs: input.buffer_after_minutes * 60_000 }
|
|
865
|
+
: {}),
|
|
866
|
+
// A retried request with the caller's key answers the hold the first one made.
|
|
867
|
+
...(input.idempotency_key !== undefined
|
|
868
|
+
? {
|
|
869
|
+
idempotencyKey: input.idempotency_key,
|
|
870
|
+
principalRef: applicationPrincipalRef(context),
|
|
871
|
+
}
|
|
872
|
+
: {}),
|
|
873
|
+
});
|
|
874
|
+
if (!result.ok)
|
|
875
|
+
throw applicationRefusal(result.code);
|
|
876
|
+
return { hold: holdOutput(result.hold) };
|
|
877
|
+
}, WRITE);
|
|
878
|
+
},
|
|
879
|
+
async getHold(context, input) {
|
|
880
|
+
const id = input.hold_id;
|
|
881
|
+
if (!STORE_ID_PATTERN.test(id))
|
|
882
|
+
throw new SlotlockAgentOperationError('hold_not_found', 404);
|
|
883
|
+
return withTenant(store, context, async (tenant) => {
|
|
884
|
+
const hold = await tenant.getHold({ tenantRef: tenantOf(context), holdId: id });
|
|
885
|
+
if (!hold)
|
|
886
|
+
throw new SlotlockAgentOperationError('hold_not_found', 404);
|
|
887
|
+
return { hold: holdOutput(hold) };
|
|
888
|
+
});
|
|
889
|
+
},
|
|
890
|
+
async listHolds(context, input) {
|
|
891
|
+
const limit = input.limit;
|
|
892
|
+
const after = parseEventCursor(input.cursor);
|
|
893
|
+
return withTenant(store, context, async (tenant) => {
|
|
894
|
+
const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
|
|
895
|
+
const { shown, next } = page(await tenant.listHolds({
|
|
896
|
+
tenantRef: tenantOf(context),
|
|
897
|
+
resourceId: resource.id,
|
|
898
|
+
limit: limit + 1,
|
|
899
|
+
...(after ? { after } : {}),
|
|
900
|
+
}), limit);
|
|
901
|
+
return { holds: shown.map(holdOutput), next_cursor: next };
|
|
902
|
+
});
|
|
903
|
+
},
|
|
904
|
+
async confirmHold(context, input) {
|
|
905
|
+
const id = input.hold_id;
|
|
906
|
+
if (!STORE_ID_PATTERN.test(id))
|
|
907
|
+
throw new SlotlockAgentOperationError('hold_not_found', 404);
|
|
908
|
+
return withTenant(store, context, async (tenant) => {
|
|
909
|
+
const result = await tenant.confirmHold(id, { tenantRef: tenantOf(context) });
|
|
910
|
+
if (!result.ok)
|
|
911
|
+
throw applicationRefusal(result.code);
|
|
912
|
+
return { reservation: reservationOutput(result.reservation) };
|
|
913
|
+
}, WRITE);
|
|
914
|
+
},
|
|
915
|
+
async releaseHold(context, input) {
|
|
916
|
+
const id = input.hold_id;
|
|
917
|
+
// An id that is not one the store issues names no hold: there is nothing to release.
|
|
918
|
+
if (!STORE_ID_PATTERN.test(id))
|
|
919
|
+
return { released: false };
|
|
920
|
+
return withTenant(store, context, async (tenant) => ({
|
|
921
|
+
released: (await tenant.releaseHold(id, { tenantRef: tenantOf(context) })).released,
|
|
922
|
+
}));
|
|
923
|
+
},
|
|
924
|
+
async getReservation(context, input) {
|
|
925
|
+
return withTenant(store, context, async (tenant) => {
|
|
926
|
+
const reservation = await tenant.getReservation({
|
|
927
|
+
tenantRef: tenantOf(context),
|
|
928
|
+
externalRef: input.reservation_ref,
|
|
929
|
+
});
|
|
930
|
+
if (!reservation)
|
|
931
|
+
throw new SlotlockAgentOperationError('reservation_not_found', 404);
|
|
932
|
+
return { reservation: reservationOutput(reservation) };
|
|
933
|
+
});
|
|
934
|
+
},
|
|
935
|
+
async listReservations(context, input) {
|
|
936
|
+
const limit = input.limit;
|
|
937
|
+
const after = parseEventCursor(input.cursor);
|
|
938
|
+
return withTenant(store, context, async (tenant) => {
|
|
939
|
+
const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
|
|
940
|
+
const { shown, next } = page(await tenant.listReservations({
|
|
941
|
+
tenantRef: tenantOf(context),
|
|
942
|
+
resourceId: resource.id,
|
|
943
|
+
window: interval(input),
|
|
944
|
+
limit: limit + 1,
|
|
945
|
+
...(after ? { after } : {}),
|
|
946
|
+
}), limit);
|
|
947
|
+
return { reservations: shown.map(reservationOutput), next_cursor: next };
|
|
948
|
+
});
|
|
949
|
+
},
|
|
950
|
+
async putReservation(context, input) {
|
|
951
|
+
return withTenant(store, context, async (tenant) => {
|
|
952
|
+
const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
|
|
953
|
+
const command = {
|
|
954
|
+
tenantRef: tenantOf(context),
|
|
955
|
+
externalRef: input.reservation_ref,
|
|
956
|
+
resourceId: resource.id,
|
|
957
|
+
start: new Date(input.start),
|
|
958
|
+
end: new Date(input.end),
|
|
959
|
+
bufferAfterMs: input.buffer_after_minutes * 60_000,
|
|
960
|
+
revision: input.revision,
|
|
961
|
+
...(input.source !== undefined ? { source: input.source } : {}),
|
|
962
|
+
};
|
|
963
|
+
const result = command.revision === 1
|
|
964
|
+
? await tenant.createExternalReservation(command)
|
|
965
|
+
: await tenant.rescheduleExternalReservation(command);
|
|
966
|
+
if (!result.ok)
|
|
967
|
+
throw applicationRefusal(result.code);
|
|
968
|
+
return { reservation: reservationOutput(result.reservation), replayed: result.idempotent };
|
|
969
|
+
}, WRITE);
|
|
970
|
+
},
|
|
971
|
+
async cancelReservation(context, input) {
|
|
972
|
+
return withTenant(store, context, async (tenant) => {
|
|
973
|
+
const result = await tenant.cancelExternalReservation({
|
|
974
|
+
tenantRef: tenantOf(context),
|
|
975
|
+
externalRef: input.reservation_ref,
|
|
976
|
+
revision: input.revision,
|
|
977
|
+
});
|
|
978
|
+
if (!result.ok)
|
|
979
|
+
throw applicationRefusal(result.code);
|
|
980
|
+
return { cancelled: result.cancelled, revision: result.revision };
|
|
981
|
+
}, WRITE);
|
|
982
|
+
},
|
|
983
|
+
async recordCoverage(context, input) {
|
|
984
|
+
return withTenant(store, context, async (tenant) => {
|
|
985
|
+
const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
|
|
986
|
+
const result = await tenant.recordCalendarCoverage({
|
|
987
|
+
tenantRef: tenantOf(context),
|
|
988
|
+
resourceId: resource.id,
|
|
989
|
+
source: input.source,
|
|
990
|
+
window: interval(input),
|
|
991
|
+
revision: input.revision,
|
|
992
|
+
});
|
|
993
|
+
if (!result.ok)
|
|
994
|
+
throw applicationRefusal(result.code);
|
|
995
|
+
return { revision: result.revision, replayed: result.idempotent };
|
|
996
|
+
}, WRITE);
|
|
997
|
+
},
|
|
998
|
+
async listTenantEvents(context, input) {
|
|
999
|
+
const limit = input.limit;
|
|
1000
|
+
const after = parseEventCursor(input.cursor);
|
|
1001
|
+
return withTenant(store, context, async (tenant) => {
|
|
1002
|
+
const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
|
|
1003
|
+
const { shown, next } = page(await tenant.listCalendarEvents({
|
|
1004
|
+
tenantRef: tenantOf(context),
|
|
1005
|
+
allOwners: true,
|
|
1006
|
+
resourceId: resource.id,
|
|
1007
|
+
window: interval(input),
|
|
1008
|
+
limit: limit + 1,
|
|
1009
|
+
...(after ? { after } : {}),
|
|
1010
|
+
}), limit);
|
|
1011
|
+
return { events: shown.map(tenantEventOutput), next_cursor: next };
|
|
1012
|
+
});
|
|
1013
|
+
},
|
|
1014
|
+
async getTenantEvent(context, input) {
|
|
1015
|
+
const id = input.event_id;
|
|
1016
|
+
if (!STORE_ID_PATTERN.test(id))
|
|
1017
|
+
throw new SlotlockAgentOperationError('event_not_found', 404);
|
|
1018
|
+
return withTenant(store, context, async (tenant) => {
|
|
1019
|
+
const event = await tenant.getCalendarEventById({ tenantRef: tenantOf(context), eventId: id });
|
|
1020
|
+
if (!event)
|
|
1021
|
+
throw new SlotlockAgentOperationError('event_not_found', 404);
|
|
1022
|
+
return { event: tenantEventOutput(event) };
|
|
1023
|
+
});
|
|
1024
|
+
},
|
|
1025
|
+
async cancelTenantEvent(context, input) {
|
|
1026
|
+
const id = input.event_id;
|
|
1027
|
+
const expectedResourceId = input.expected_resource_id;
|
|
1028
|
+
// An id the store never issues names no event, and no event is on a resource it never issued.
|
|
1029
|
+
if (!STORE_ID_PATTERN.test(id) ||
|
|
1030
|
+
(expectedResourceId !== undefined && !STORE_ID_PATTERN.test(expectedResourceId))) {
|
|
1031
|
+
throw new SlotlockAgentOperationError('event_not_found', 404);
|
|
1032
|
+
}
|
|
1033
|
+
return withTenant(store, context, async (tenant) => {
|
|
1034
|
+
const result = await tenant.cancelCalendarEventById({
|
|
1035
|
+
tenantRef: tenantOf(context),
|
|
1036
|
+
eventId: id,
|
|
1037
|
+
idempotencyKey: applicationCommandKey(context, input.idempotency_key),
|
|
1038
|
+
expectedRevision: input.expected_revision,
|
|
1039
|
+
...(expectedResourceId !== undefined ? { expectedResourceId } : {}),
|
|
1040
|
+
});
|
|
1041
|
+
if (!result.ok)
|
|
1042
|
+
throw applicationRefusal(result.code);
|
|
1043
|
+
return {
|
|
1044
|
+
event_id: result.eventId,
|
|
1045
|
+
revision: result.revision,
|
|
1046
|
+
cancelled: true,
|
|
1047
|
+
replayed: result.idempotent,
|
|
1048
|
+
};
|
|
1049
|
+
}, WRITE);
|
|
1050
|
+
},
|
|
1051
|
+
async getTenant(context) {
|
|
1052
|
+
return withTenant(store, context, async (tenant) => ({
|
|
1053
|
+
tenant: {
|
|
1054
|
+
ref: tenantOf(context),
|
|
1055
|
+
suspended: await tenant.isTenantSuspended({ tenantRef: tenantOf(context) }),
|
|
1056
|
+
},
|
|
1057
|
+
}));
|
|
1058
|
+
},
|
|
1059
|
+
async eraseTenant(context, input) {
|
|
1060
|
+
if (!tenantErasure)
|
|
1061
|
+
throw new SlotlockAgentOperationError('tenant_erasure_disabled', 403);
|
|
1062
|
+
// The reference, not a yes: a key of another tenant than the caller believes cannot erase it.
|
|
1063
|
+
if (input.confirm_tenant_ref !== tenantOf(context)) {
|
|
1064
|
+
throw new SlotlockAgentOperationError('invalid_confirmation', 400);
|
|
1065
|
+
}
|
|
1066
|
+
return withTenant(store, context, async (tenant) => {
|
|
1067
|
+
// Read under the tenant's lock, which a suspension waits for: a suspended tenant's data is
|
|
1068
|
+
// kept for its operator, who erases it (slotlock tenant erase) when they decide to.
|
|
1069
|
+
if (await tenant.isTenantSuspended({ tenantRef: tenantOf(context) })) {
|
|
1070
|
+
throw new SlotlockAgentOperationError('tenant_suspended', 403);
|
|
1071
|
+
}
|
|
1072
|
+
const erased = await tenant.eraseTenant({ tenantRef: tenantOf(context) });
|
|
1073
|
+
return { total: erased.total, rows: erased.rows };
|
|
1074
|
+
}, WRITE);
|
|
1075
|
+
},
|
|
1076
|
+
async runMaintenance(context) {
|
|
1077
|
+
assertActive(context);
|
|
1078
|
+
try {
|
|
1079
|
+
// A caller goes round again while `capped`; when to do so at once is the scheduler's.
|
|
1080
|
+
const { rollPending: _, ...result } = await maintainSlotlockTenant(store, tenantOf(context), {
|
|
1081
|
+
batches,
|
|
1082
|
+
signal: context.signal,
|
|
1083
|
+
});
|
|
1084
|
+
return result;
|
|
1085
|
+
}
|
|
1086
|
+
catch (error) {
|
|
1087
|
+
if (context.signal.aborted)
|
|
1088
|
+
throw new SlotlockAgentOperationError('request_aborted', 503);
|
|
1089
|
+
throw error;
|
|
1090
|
+
}
|
|
1091
|
+
},
|
|
518
1092
|
};
|
|
519
1093
|
}
|