slotlock 0.1.0 → 0.2.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 +138 -0
- package/CONTRIBUTING.md +35 -9
- package/README.md +153 -79
- package/SECURITY.md +40 -4
- package/SPEC.md +19 -6
- package/agent-server.d.ts +64 -7
- package/agent-server.js +273 -68
- package/agent-store-backend.d.ts +16 -0
- package/agent-store-backend.js +257 -2
- package/api-keys.d.ts +11 -4
- package/api-keys.js +9 -2
- package/application-api.d.ts +54 -0
- package/application-api.js +290 -0
- package/dashboard.d.ts +4 -1
- package/dashboard.js +6 -4
- package/ddl.d.ts +2 -1
- package/ddl.js +143 -15
- package/hooks.d.ts +7 -0
- package/hooks.js +13 -0
- package/index.d.ts +8 -4
- package/index.js +4 -2
- package/maintenance.d.ts +57 -0
- package/maintenance.js +181 -0
- package/node-server.js +121 -5
- package/package.json +1 -1
- package/rest-api.d.ts +33 -18
- package/rest-api.js +131 -63
- package/rules.d.ts +19 -0
- package/rules.js +32 -4
- package/self-host.d.ts +5 -2
- package/self-host.js +127 -20
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +30 -8
- package/store.js +69 -27
- package/sync.js +530 -49
- package/timezone.d.ts +19 -0
- package/timezone.js +16 -1
- package/types.d.ts +4 -1
- package/weekly-hours.js +2 -2
package/self-host.d.ts
CHANGED
|
@@ -33,7 +33,10 @@ export interface SlotlockServeConfig extends SlotlockDatabaseConfig {
|
|
|
33
33
|
availability: readonly WeeklyAvailabilityRule[];
|
|
34
34
|
/** Set exactly when SLOTLOCK_GITHUB_CLIENT_ID is. */
|
|
35
35
|
dashboard?: SlotlockServeDashboardConfig;
|
|
36
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* Serve the REST API under /v1, with the application API for admin keys, and its OpenAPI document
|
|
38
|
+
* (SLOTLOCK_REST_API: on unless off).
|
|
39
|
+
*/
|
|
37
40
|
restApi: boolean;
|
|
38
41
|
}
|
|
39
42
|
export declare function readSlotlockDatabaseConfig(env: SlotlockEnv): SlotlockDatabaseConfig;
|
|
@@ -84,7 +87,7 @@ export interface SlotlockRunningServer {
|
|
|
84
87
|
}
|
|
85
88
|
/** Serve MCP and A2A over HTTP from the Postgres store, as one token-authenticated tenant. */
|
|
86
89
|
export declare function startSlotlockServer(config: SlotlockServeConfig, log: SlotlockLogger): Promise<SlotlockRunningServer>;
|
|
87
|
-
export declare const SLOTLOCK_CLI_HELP = "Usage: slotlock <command> [options]\n\nCommands:\n migrate Apply the schema and row-level security, then grant the serving\n role when SLOTLOCK_MIGRATE_DATABASE_URL names another role\n serve [--migrate] Serve MCP and
|
|
90
|
+
export declare const SLOTLOCK_CLI_HELP = "Usage: slotlock <command> [options]\n\nCommands:\n migrate Apply the schema and row-level security, then grant the serving\n role when SLOTLOCK_MIGRATE_DATABASE_URL names another role\n serve [--migrate] Serve MCP, A2A and REST over HTTP (--migrate runs migrate first);\n maintains each tenant it serves every hour\n maintain Run maintenance for the tenant now: extend recurring events to the\n rolling horizon and prune past the retention window (for cron)\n resource add <ref> [--timezone <IANA zone>]\n Create a calendar resource (a car, room, person or machine)\n resource list Print the tenant's resources, one JSON object per line\n hours show [--resource <ref>]\n Print each resource's bookable hours (null: the server's)\n hours set <json> (--resource <ref> | --all)\n Give one resource, or every one, weekly hours of its own; [] closes\n hours clear (--resource <ref> | --all)\n Hand one resource, or every one, back to the server's hours\n key create <name> [--scope read,write,admin] [--expires-in-days <n>]\n Create an API key for the tenant (default scope read,write, no\n expiry; admin adds the application API) and print it once; only its\n SHA-256 is stored\n key list Print the tenant's API keys, one JSON object per line\n key rotate <id> Give an active key a new secret and print it once; the old one stops\n working at once, and the key keeps its id and what it booked\n key revoke <id> Revoke one of the tenant's API keys at once\n healthcheck Exit 0 when the local server reports ready\n\nOptions:\n -h, --help Show this help\n -v, --version Print the version\n\nEnvironment:\n DATABASE_URL postgres:// URL of the role Slotlock serves as (required); serve\n refuses a superuser or a role with BYPASSRLS\n SLOTLOCK_MIGRATE_DATABASE_URL postgres:// URL of the schema owner, for migrate (optional)\n SLOTLOCK_AUTH_TOKEN Optional bearer token for SLOTLOCK_TENANT with every scope,\n 32+ random characters (serve); API keys work with or without it\n SLOTLOCK_CONFIRMATION_SECRET Signs pending write confirmations, 32+ random characters (serve)\n SLOTLOCK_CONFIRM_WRITES all (default), none, or a comma-separated list of write tools\n SLOTLOCK_PUBLIC_URL URL clients use (default http://localhost:$PORT)\n SLOTLOCK_TENANT Tenant of the token, resource and key commands (default \"default\")\n SLOTLOCK_AVAILABILITY JSON array of weekly bookable-hours rules (default: none)\n SLOTLOCK_REST_API on (default): also serve the tools as a REST API under /v1, and\n the application API for admin keys, described at /openapi.json;\n off: MCP and A2A only\n SLOTLOCK_GITHUB_CLIENT_ID Serve the dashboard at $SLOTLOCK_PUBLIC_URL/dashboard: the Client ID\n of a GitHub OAuth app with callback .../dashboard/callback\n SLOTLOCK_GITHUB_CLIENT_SECRET That app's client secret (dashboard)\n SLOTLOCK_SESSION_SECRET Signs dashboard sessions, 32+ random characters (dashboard)\n SLOTLOCK_DASHBOARD_USERS * or comma-separated GitHub user ids that may sign in (dashboard)\n HOST Listen address (default 127.0.0.1)\n PORT Listen port (default 8080)\n\nGenerate each secret with: openssl rand -hex 32\n";
|
|
88
91
|
/**
|
|
89
92
|
* Run one `slotlock` command and resolve to its exit code: 0 success, 1 failure, 2 a usage or
|
|
90
93
|
* configuration error (nothing was started). `serve` resolves once `io.signal` aborts and it has
|
package/self-host.js
CHANGED
|
@@ -7,11 +7,13 @@ import { createHash, timingSafeEqual } from 'node:crypto';
|
|
|
7
7
|
import { parseArgs } from 'node:util';
|
|
8
8
|
import postgres from 'postgres';
|
|
9
9
|
import { createSlotlockAgentServer, slotlockAgentOperationScope, } from './agent-server.js';
|
|
10
|
-
import { createSlotlockStoreAgentBackend } from './agent-store-backend.js';
|
|
11
|
-
import { SLOTLOCK_API_KEY_MAX_LIFETIME_DAYS, SLOTLOCK_API_KEY_SCOPES, createSlotlockApiKeyAuthenticator, createSlotlockApiKeyStore, slotlockBearerCredential, } from './api-keys.js';
|
|
10
|
+
import { createSlotlockStoreAgentBackend, createSlotlockStoreApplicationBackend, } from './agent-store-backend.js';
|
|
11
|
+
import { SLOTLOCK_API_KEY_DEFAULT_SCOPES, SLOTLOCK_API_KEY_MAX_LIFETIME_DAYS, SLOTLOCK_API_KEY_SCOPES, createSlotlockApiKeyAuthenticator, createSlotlockApiKeyStore, slotlockBearerCredential, } from './api-keys.js';
|
|
12
12
|
import { createSlotlockDashboard, createSlotlockDashboardResources, createSlotlockDashboardState, } from './dashboard.js';
|
|
13
13
|
import { SECRET_HINT, SlotlockConfigError, optional, readAvailability, readDashboardConfig, readSecret, readServerUrl, } from './config.js';
|
|
14
|
-
import {
|
|
14
|
+
import { SLOTLOCK_APPLICATION_SCOPE } from './application-api.js';
|
|
15
|
+
import { SLOTLOCK_DASHBOARD_FUNCTIONS, SLOTLOCK_TENANT_TABLES } from './ddl.js';
|
|
16
|
+
import { maintainSlotlockTenant, startSlotlockMaintenance, withSlotlockMaintenanceLock, } from './maintenance.js';
|
|
15
17
|
import { createSlotlockNodeServer, } from './node-server.js';
|
|
16
18
|
import { slotlockPackageVersion } from './package-version.js';
|
|
17
19
|
import { createSlotlockStore } from './store.js';
|
|
@@ -43,6 +45,15 @@ const SELF_HOST_SUBJECT = 'self-host';
|
|
|
43
45
|
/** Who `key create` records as a key's creator. */
|
|
44
46
|
const CLI_KEY_CREATOR = 'cli';
|
|
45
47
|
const DAY_MS = 86_400_000;
|
|
48
|
+
/**
|
|
49
|
+
* The most resources `serve` lets one tenant create through the application API. A dashboard open to
|
|
50
|
+
* every GitHub user gives each a tenant, so the cap keeps one from filling the database.
|
|
51
|
+
*/
|
|
52
|
+
const SERVE_MAX_TENANT_RESOURCES = 10_000;
|
|
53
|
+
/** How often `serve` maintains each tenant it serves (README, Operations: at least daily). */
|
|
54
|
+
const SERVE_MAINTENANCE_INTERVAL_MS = 60 * 60 * 1_000;
|
|
55
|
+
/** Passes `slotlock maintain` runs at most, each as big as `serve`'s, until nothing is left. */
|
|
56
|
+
const MAINTAIN_MAX_PASSES = 100;
|
|
46
57
|
const WRITE_OPERATIONS = [
|
|
47
58
|
'slotlock_create_event',
|
|
48
59
|
'slotlock_update_event',
|
|
@@ -138,7 +149,7 @@ export function readSlotlockDatabaseConfig(env) {
|
|
|
138
149
|
return config;
|
|
139
150
|
}
|
|
140
151
|
function readRestApi(env) {
|
|
141
|
-
const value = optional(env, 'SLOTLOCK_REST_API') ?? '
|
|
152
|
+
const value = optional(env, 'SLOTLOCK_REST_API') ?? 'on';
|
|
142
153
|
if (value !== 'on' && value !== 'off') {
|
|
143
154
|
throw new SlotlockConfigError('SLOTLOCK_REST_API must be on or off');
|
|
144
155
|
}
|
|
@@ -272,6 +283,13 @@ function describeError(error) {
|
|
|
272
283
|
}
|
|
273
284
|
return detail;
|
|
274
285
|
}
|
|
286
|
+
/** An agent server event's fields under the log's snake_case keys (`durationMs` → `duration_ms`). */
|
|
287
|
+
function snakeCaseKeys(fields) {
|
|
288
|
+
return Object.fromEntries(Object.entries(fields).map(([key, value]) => [
|
|
289
|
+
key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`),
|
|
290
|
+
value,
|
|
291
|
+
]));
|
|
292
|
+
}
|
|
275
293
|
function connect(url, max) {
|
|
276
294
|
return postgres(url, { max, onnotice: () => { }, connect_timeout: 10 });
|
|
277
295
|
}
|
|
@@ -358,6 +376,20 @@ export async function startSlotlockServer(config, log) {
|
|
|
358
376
|
if (!schema?.present) {
|
|
359
377
|
throw Object.assign(new Error('The slotlock schema is missing: run `slotlock migrate` or `slotlock serve --migrate`'), { code: 'schema_missing' });
|
|
360
378
|
}
|
|
379
|
+
// A deployment run statement by statement lifts forced row-level security for its data steps
|
|
380
|
+
// and, stopped part way, leaves it lifted until the next one puts it back. The owning role then
|
|
381
|
+
// reads every tenant's rows: refuse to serve until then.
|
|
382
|
+
const lifted = await sql `
|
|
383
|
+
SELECT rel.relname AS name
|
|
384
|
+
FROM pg_catalog.pg_class rel
|
|
385
|
+
JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace
|
|
386
|
+
WHERE ns.nspname = 'slotlock'
|
|
387
|
+
AND rel.relname IN ${sql(SLOTLOCK_TENANT_TABLES)}
|
|
388
|
+
AND rel.relrowsecurity AND NOT rel.relforcerowsecurity
|
|
389
|
+
ORDER BY rel.relname`;
|
|
390
|
+
if (lifted.length > 0) {
|
|
391
|
+
throw Object.assign(new Error(`Row-level security is enabled but not forced on ${lifted.map(({ name }) => `slotlock.${name}`).join(', ')}: a deployment stopped part way. Run \`slotlock migrate\` (or \`slotlock serve --migrate\`) to put it back`), { code: 'row_security_lifted' });
|
|
392
|
+
}
|
|
361
393
|
if (config.dashboard) {
|
|
362
394
|
for (const signature of SLOTLOCK_DASHBOARD_FUNCTIONS) {
|
|
363
395
|
const [granted] = await sql `
|
|
@@ -373,11 +405,15 @@ export async function startSlotlockServer(config, log) {
|
|
|
373
405
|
const authenticateKey = createSlotlockApiKeyAuthenticator(createSlotlockApiKeyStore(sql));
|
|
374
406
|
const authenticateToken = config.authToken === undefined
|
|
375
407
|
? undefined
|
|
376
|
-
:
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
408
|
+
: // Read and write, never admin: this is the token the docs put in agents' configuration,
|
|
409
|
+
// and the application API's writes ask no person. An admin key is made for the purpose.
|
|
410
|
+
createSlotlockTokenAuthenticator(config.authToken, {
|
|
411
|
+
subject: SELF_HOST_SUBJECT,
|
|
412
|
+
tenantRef: config.tenantRef,
|
|
413
|
+
scopes: SLOTLOCK_API_KEY_DEFAULT_SCOPES,
|
|
414
|
+
});
|
|
415
|
+
// Set once the listener is up; the authenticator tells it which tenants this server serves.
|
|
416
|
+
let maintenance;
|
|
381
417
|
const agentServer = createSlotlockAgentServer({
|
|
382
418
|
publicBaseUrl: config.publicUrl,
|
|
383
419
|
// readPublicUrl admits plain HTTP only on a loopback host, which is all this permits anyway.
|
|
@@ -389,18 +425,32 @@ export async function startSlotlockServer(config, log) {
|
|
|
389
425
|
// dashboard no longer allows loses their keys with their session.
|
|
390
426
|
authenticate: async (request) => {
|
|
391
427
|
const principal = (await authenticateToken?.(request)) ?? (await authenticateKey(request));
|
|
392
|
-
|
|
428
|
+
if (!principal || !dashboardAllows(config, principal.tenantRef))
|
|
429
|
+
return null;
|
|
430
|
+
maintenance?.touch(principal.tenantRef);
|
|
431
|
+
return principal;
|
|
393
432
|
},
|
|
394
433
|
authorize: async ({ principal, operation }) => authorizeSlotlockServeOperation({ principal, operation }),
|
|
395
434
|
health: async () => (await databaseReachable(sql))
|
|
396
435
|
? { ready: true, checks: ['database'] }
|
|
397
436
|
: { ready: false, checks: ['database_unreachable'] },
|
|
398
437
|
// Outcomes and counts only: the server never reports arguments or identities.
|
|
399
|
-
onEvent: ({ type, ...fields }) => log.info(type, fields),
|
|
438
|
+
onEvent: ({ type, ...fields }) => log.info(type, snakeCaseKeys(fields)),
|
|
400
439
|
// What an agent sees only as internal_error: the operation and the error's message and code,
|
|
401
440
|
// never the arguments or the request, with every configured secret redacted.
|
|
402
441
|
onError: (error, { operation }) => log.error('agent_error', { operation, ...describeError(error) }),
|
|
403
|
-
...(config.restApi
|
|
442
|
+
...(config.restApi
|
|
443
|
+
? {
|
|
444
|
+
rest: true,
|
|
445
|
+
// Resources, hours, holds, reservations, coverage and maintenance: admin keys only.
|
|
446
|
+
application: {
|
|
447
|
+
backend: createSlotlockStoreApplicationBackend(store, {
|
|
448
|
+
maxTenantResources: SERVE_MAX_TENANT_RESOURCES,
|
|
449
|
+
}),
|
|
450
|
+
authorize: async ({ principal }) => principal.scopes?.includes(SLOTLOCK_APPLICATION_SCOPE) === true,
|
|
451
|
+
},
|
|
452
|
+
}
|
|
453
|
+
: {}),
|
|
404
454
|
...(config.confirmationSecret !== undefined
|
|
405
455
|
? {
|
|
406
456
|
confirmation: {
|
|
@@ -437,6 +487,15 @@ export async function startSlotlockServer(config, log) {
|
|
|
437
487
|
onError: (error, phase) => log.error('http_error', { phase, ...describeError(error) }),
|
|
438
488
|
});
|
|
439
489
|
const address = await listener.listen({ host: config.host, port: config.port });
|
|
490
|
+
maintenance = startSlotlockMaintenance({
|
|
491
|
+
store,
|
|
492
|
+
sql,
|
|
493
|
+
tenants: [config.tenantRef],
|
|
494
|
+
intervalMs: SERVE_MAINTENANCE_INTERVAL_MS,
|
|
495
|
+
// Counts only, never the tenant.
|
|
496
|
+
onPass: (result) => log.info('maintenance', { ...result }),
|
|
497
|
+
onError: (error) => log.error('maintenance_failed', describeError(error)),
|
|
498
|
+
});
|
|
440
499
|
log.info('listening', {
|
|
441
500
|
address: address.origin,
|
|
442
501
|
mcp: `${config.publicUrl}/mcp`,
|
|
@@ -446,11 +505,14 @@ export async function startSlotlockServer(config, log) {
|
|
|
446
505
|
dashboard: dashboard ? `${config.publicUrl}/dashboard` : null,
|
|
447
506
|
confirm_writes: config.confirmWrites,
|
|
448
507
|
availability_rules: config.availability.length,
|
|
508
|
+
rest: config.restApi ? `${config.publicUrl}/openapi.json` : null,
|
|
509
|
+
maintenance_interval_minutes: SERVE_MAINTENANCE_INTERVAL_MS / 60_000,
|
|
449
510
|
});
|
|
450
511
|
return {
|
|
451
512
|
address,
|
|
452
513
|
async close() {
|
|
453
514
|
const result = await listener.close();
|
|
515
|
+
await maintenance?.close();
|
|
454
516
|
await sql.end({ timeout: 5 });
|
|
455
517
|
return result;
|
|
456
518
|
},
|
|
@@ -579,17 +641,47 @@ function apiKeyLine(apiKey, key) {
|
|
|
579
641
|
...(key !== undefined ? { key } : {}),
|
|
580
642
|
})}\n`;
|
|
581
643
|
}
|
|
582
|
-
/** `--scope`: read, write
|
|
644
|
+
/** `--scope`: read, write and admin, comma-separated; read,write unless given. */
|
|
583
645
|
function parseKeyScopes(value) {
|
|
584
646
|
if (value === undefined)
|
|
585
|
-
return [...
|
|
647
|
+
return [...SLOTLOCK_API_KEY_DEFAULT_SCOPES];
|
|
586
648
|
const names = value.split(',').map((name) => name.trim());
|
|
587
649
|
const scopes = SLOTLOCK_API_KEY_SCOPES.filter((scope) => names.includes(scope));
|
|
588
650
|
if (names.length === 0 || names.some((name) => !scopes.includes(name))) {
|
|
589
|
-
throw new Error('--scope must be read, write
|
|
651
|
+
throw new Error('--scope must be a comma-separated list of read, write and admin');
|
|
590
652
|
}
|
|
591
653
|
return scopes;
|
|
592
654
|
}
|
|
655
|
+
/**
|
|
656
|
+
* `maintain`: maintenance passes for the tenant until nothing is left, under the tenant's lock, so
|
|
657
|
+
* it never repeats what a server is doing. Null when another server holds the lock.
|
|
658
|
+
*/
|
|
659
|
+
async function maintainTenant(config) {
|
|
660
|
+
const sql = connect(config.databaseUrl, 2);
|
|
661
|
+
try {
|
|
662
|
+
const store = createSlotlockStore(sql);
|
|
663
|
+
const total = await withSlotlockMaintenanceLock(sql, config.tenantRef, async () => {
|
|
664
|
+
const sum = { extended: 0, conflicts: 0, refused: 0, pruned: 0, capped: true, passes: 0 };
|
|
665
|
+
while (sum.capped && sum.passes < MAINTAIN_MAX_PASSES) {
|
|
666
|
+
const pass = await maintainSlotlockTenant(store, config.tenantRef);
|
|
667
|
+
sum.passes += 1;
|
|
668
|
+
sum.extended += pass.extended;
|
|
669
|
+
sum.conflicts += pass.conflicts;
|
|
670
|
+
sum.refused += pass.refused;
|
|
671
|
+
sum.pruned += pass.pruned;
|
|
672
|
+
// A capped pass that got nowhere met only series it cannot extend: another would too.
|
|
673
|
+
sum.capped = pass.capped;
|
|
674
|
+
if (pass.capped && pass.extended + pass.pruned === 0)
|
|
675
|
+
break;
|
|
676
|
+
}
|
|
677
|
+
return sum;
|
|
678
|
+
});
|
|
679
|
+
return total ?? null;
|
|
680
|
+
}
|
|
681
|
+
finally {
|
|
682
|
+
await sql.end({ timeout: 5 });
|
|
683
|
+
}
|
|
684
|
+
}
|
|
593
685
|
/** `--expires-in-days`: a whole number of days up to the longest a key may live. */
|
|
594
686
|
function parseKeyLifetime(value) {
|
|
595
687
|
if (value === undefined)
|
|
@@ -632,7 +724,10 @@ export const SLOTLOCK_CLI_HELP = `Usage: slotlock <command> [options]
|
|
|
632
724
|
Commands:
|
|
633
725
|
migrate Apply the schema and row-level security, then grant the serving
|
|
634
726
|
role when SLOTLOCK_MIGRATE_DATABASE_URL names another role
|
|
635
|
-
serve [--migrate] Serve MCP and
|
|
727
|
+
serve [--migrate] Serve MCP, A2A and REST over HTTP (--migrate runs migrate first);
|
|
728
|
+
maintains each tenant it serves every hour
|
|
729
|
+
maintain Run maintenance for the tenant now: extend recurring events to the
|
|
730
|
+
rolling horizon and prune past the retention window (for cron)
|
|
636
731
|
resource add <ref> [--timezone <IANA zone>]
|
|
637
732
|
Create a calendar resource (a car, room, person or machine)
|
|
638
733
|
resource list Print the tenant's resources, one JSON object per line
|
|
@@ -642,9 +737,10 @@ Commands:
|
|
|
642
737
|
Give one resource, or every one, weekly hours of its own; [] closes
|
|
643
738
|
hours clear (--resource <ref> | --all)
|
|
644
739
|
Hand one resource, or every one, back to the server's hours
|
|
645
|
-
key create <name> [--scope read
|
|
740
|
+
key create <name> [--scope read,write,admin] [--expires-in-days <n>]
|
|
646
741
|
Create an API key for the tenant (default scope read,write, no
|
|
647
|
-
expiry) and print it once; only its
|
|
742
|
+
expiry; admin adds the application API) and print it once; only its
|
|
743
|
+
SHA-256 is stored
|
|
648
744
|
key list Print the tenant's API keys, one JSON object per line
|
|
649
745
|
key rotate <id> Give an active key a new secret and print it once; the old one stops
|
|
650
746
|
working at once, and the key keeps its id and what it booked
|
|
@@ -666,8 +762,9 @@ Environment:
|
|
|
666
762
|
SLOTLOCK_PUBLIC_URL URL clients use (default http://localhost:$PORT)
|
|
667
763
|
SLOTLOCK_TENANT Tenant of the token, resource and key commands (default "default")
|
|
668
764
|
SLOTLOCK_AVAILABILITY JSON array of weekly bookable-hours rules (default: none)
|
|
669
|
-
SLOTLOCK_REST_API on: also serve the tools as a REST API under /v1,
|
|
670
|
-
|
|
765
|
+
SLOTLOCK_REST_API on (default): also serve the tools as a REST API under /v1, and
|
|
766
|
+
the application API for admin keys, described at /openapi.json;
|
|
767
|
+
off: MCP and A2A only
|
|
671
768
|
SLOTLOCK_GITHUB_CLIENT_ID Serve the dashboard at $SLOTLOCK_PUBLIC_URL/dashboard: the Client ID
|
|
672
769
|
of a GitHub OAuth app with callback .../dashboard/callback
|
|
673
770
|
SLOTLOCK_GITHUB_CLIENT_SECRET That app's client secret (dashboard)
|
|
@@ -715,6 +812,7 @@ export async function runSlotlockCli(argv, io) {
|
|
|
715
812
|
const expectedOperands = {
|
|
716
813
|
migrate: 0,
|
|
717
814
|
serve: 0,
|
|
815
|
+
maintain: 0,
|
|
718
816
|
healthcheck: 0,
|
|
719
817
|
'resource add': 2,
|
|
720
818
|
'resource list': 1,
|
|
@@ -797,6 +895,15 @@ export async function runSlotlockCli(argv, io) {
|
|
|
797
895
|
log.info('stopped', { forced });
|
|
798
896
|
return 0;
|
|
799
897
|
}
|
|
898
|
+
case 'maintain': {
|
|
899
|
+
const result = await maintainTenant(readSlotlockDatabaseConfig(io.env));
|
|
900
|
+
if (!result) {
|
|
901
|
+
io.stderr.write('slotlock: another server is maintaining this tenant now; nothing to do\n');
|
|
902
|
+
return 0;
|
|
903
|
+
}
|
|
904
|
+
io.stdout.write(`${JSON.stringify(result)}\n`);
|
|
905
|
+
return 0;
|
|
906
|
+
}
|
|
800
907
|
case 'resource add': {
|
|
801
908
|
const config = readSlotlockDatabaseConfig(io.env);
|
|
802
909
|
const resource = await addResource(config, operands[1], values.timezone ?? 'UTC');
|
package/slotlock.manifest.json
CHANGED
package/store.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Sql, TransactionSql } from 'postgres';
|
|
2
|
+
import { SLOTLOCK_CALENDAR_HORIZON_DAYS } from './rules.js';
|
|
2
3
|
import type { AcquireHoldResult, CalendarCoverageCommand, CalendarCoverageResult, CalendarEventRetentionResult, CalendarFreeBusy, CalendarHorizonRollCursor, CalendarHorizonRollResult, CancelCalendarEventResult, CancelExternalReservationResult, ConfirmHoldResult, CreateReservationResult, ExternalReservationCommand, ExternalReservationResult, Interval, SlotlockCalendarEvent, SlotlockResource, PutCalendarEventCommand, PutCalendarEventResult, ReleaseHoldResult, WeeklyAvailabilityRule } from './types.js';
|
|
3
|
-
export
|
|
4
|
+
export { SLOTLOCK_CALENDAR_HORIZON_DAYS };
|
|
4
5
|
/**
|
|
5
6
|
* Longest one-off event the store accepts (ten years and some leap days): long enough for any lease
|
|
6
7
|
* or long-term rental, short enough that a unit error cannot occupy a resource for ever. A recurring
|
|
@@ -120,7 +121,11 @@ export interface SlotlockStore {
|
|
|
120
121
|
end: Date;
|
|
121
122
|
source?: string;
|
|
122
123
|
}): Promise<CreateReservationResult>;
|
|
123
|
-
/**
|
|
124
|
+
/**
|
|
125
|
+
* Idempotently create a tenant-owned booking reservation by its stable business identity. The
|
|
126
|
+
* rows a calendar event occupies are not bookings: here, in reschedule and in cancel their
|
|
127
|
+
* references answer as references no booking holds.
|
|
128
|
+
*/
|
|
124
129
|
createExternalReservation(params: ExternalReservationCommand): Promise<ExternalReservationResult>;
|
|
125
130
|
/** Atomically move an existing tenant-owned booking; a conflict leaves its old range intact. */
|
|
126
131
|
rescheduleExternalReservation(params: ExternalReservationCommand): Promise<ExternalReservationResult>;
|
|
@@ -141,10 +146,17 @@ export interface SlotlockStore {
|
|
|
141
146
|
ttlMs: number;
|
|
142
147
|
source?: string;
|
|
143
148
|
}): Promise<AcquireHoldResult>;
|
|
144
|
-
/**
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
149
|
+
/**
|
|
150
|
+
* Flip an unexpired hold to a confirmed reservation (expiry cleared). Confirming it again answers
|
|
151
|
+
* the same reservation. With `tenantRef`, only a hold on one of that tenant's resources counts:
|
|
152
|
+
* another tenant's is `hold_not_found` whatever the connection's row-level security.
|
|
153
|
+
*/
|
|
154
|
+
confirmHold(holdId: string, options?: SlotlockHoldTenantOptions): Promise<ConfirmHoldResult>;
|
|
155
|
+
/**
|
|
156
|
+
* Delete a live hold; released=false when it was already gone (expired-reaped or confirmed). With
|
|
157
|
+
* `tenantRef`, only a hold on one of that tenant's resources, as for confirmHold.
|
|
158
|
+
*/
|
|
159
|
+
releaseHold(holdId: string, options?: SlotlockHoldTenantOptions): Promise<ReleaseHoldResult>;
|
|
148
160
|
/** Reservations overlapping `window` for one resource, as merged-ready half-open intervals. */
|
|
149
161
|
listBusy(resourceId: string, window: Interval): Promise<Interval[]>;
|
|
150
162
|
/** Create or revise one trusted event under expected-revision + tenant idempotency guards. */
|
|
@@ -182,6 +194,10 @@ export interface SlotlockStore {
|
|
|
182
194
|
/**
|
|
183
195
|
* Atomically rematerialize due recurring masters into one rolling window without changing their
|
|
184
196
|
* semantic revision. A collision rolls back that master and leaves its old coverage untouched.
|
|
197
|
+
* A master is rolled from the window's start through the later of its stored end and the
|
|
198
|
+
* window's, so its end never moves back. One that already covers the window, or whose end lies
|
|
199
|
+
* too far past it for the 367-day cap from the window's start, is not due: it is left as it is
|
|
200
|
+
* and not counted.
|
|
185
201
|
*/
|
|
186
202
|
rollCalendarEventHorizon(params: {
|
|
187
203
|
tenantRef: string;
|
|
@@ -215,7 +231,10 @@ export interface SlotlockStore {
|
|
|
215
231
|
requiredSources?: string[];
|
|
216
232
|
coverageMaxAgeMs?: number;
|
|
217
233
|
}): Promise<CalendarFreeBusy>;
|
|
218
|
-
/**
|
|
234
|
+
/**
|
|
235
|
+
* Convenience composition: listBusy × expandRules (in the resource's stored timezone) →
|
|
236
|
+
* findNextAvailable. A search window longer than 367 days is refused (`invalid_window`).
|
|
237
|
+
*/
|
|
219
238
|
findNextAvailableFor(params: {
|
|
220
239
|
resourceId: string;
|
|
221
240
|
rules: WeeklyAvailabilityRule[];
|
|
@@ -232,5 +251,8 @@ export interface SlotlockStoreOptions {
|
|
|
232
251
|
/** Hard retained-command ceiling per non-internal owner. Defaults to 10,000; cannot be disabled. */
|
|
233
252
|
agentOwnerCommandQuota?: number;
|
|
234
253
|
}
|
|
254
|
+
export interface SlotlockHoldTenantOptions {
|
|
255
|
+
/** Only a hold on one of this tenant's resources: 1-500 UTF-8 bytes. */
|
|
256
|
+
tenantRef?: string;
|
|
257
|
+
}
|
|
235
258
|
export declare function createSlotlockStore(sql: StoreSql, options?: SlotlockStoreOptions): SlotlockStore;
|
|
236
|
-
export {};
|
package/store.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { createHash, randomUUID } from 'node:crypto';
|
|
5
5
|
import { SLOTLOCK_CORE_DDL, SLOTLOCK_SCHEMA_CONTROL_REFUSAL, SLOTLOCK_TENANT_CONTEXT_SETTING, createSlotlockApplicationRoleGrantsDdl, createSlotlockTenantRlsDdl, } from './ddl.js';
|
|
6
6
|
import { findNextAvailable, mergeIntervals } from './engine.js';
|
|
7
|
-
import { expandRules } from './rules.js';
|
|
7
|
+
import { SLOTLOCK_CALENDAR_HORIZON_DAYS, assertBoundedSearchWindow, expandRules, invalidWindowError, } from './rules.js';
|
|
8
8
|
import { CalendarContractError, CalendarExpansionLimitError, expandCalendarEventSeries, normalizeCalendarEventContent, } from './sync.js';
|
|
9
9
|
import { parseAvailabilityRules } from './weekly-hours.js';
|
|
10
10
|
/** Postgres exclusion_violation — the EXCLUDE constraint fired. */
|
|
@@ -21,7 +21,7 @@ const SERIALIZATION_FAILURE = '40001';
|
|
|
21
21
|
const MAX_HOLD_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
22
22
|
/** Turnaround cannot occupy a resource indefinitely because of a caller/unit error. */
|
|
23
23
|
const MAX_BUFFER_AFTER_MS = 30 * 24 * 60 * 60 * 1000;
|
|
24
|
-
export
|
|
24
|
+
export { SLOTLOCK_CALENDAR_HORIZON_DAYS };
|
|
25
25
|
/**
|
|
26
26
|
* Longest one-off event the store accepts (ten years and some leap days): long enough for any lease
|
|
27
27
|
* or long-term rental, short enough that a unit error cannot occupy a resource for ever. A recurring
|
|
@@ -89,6 +89,19 @@ function resumeStoredSeries(event, window, found) {
|
|
|
89
89
|
function seriesCursorJson(tx, cursor) {
|
|
90
90
|
return cursor === null ? null : tx.json(cursor);
|
|
91
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* The window a roll to `horizon` gives a series stored for `stored`: from the horizon's start to
|
|
94
|
+
* the later of the two ends, so its end never moves back. `null` keeps the stored window: it covers
|
|
95
|
+
* the horizon already, or it ends so far past it that no window from the horizon's start to its end
|
|
96
|
+
* fits the 367-day cap (it rolls once the horizon is close enough).
|
|
97
|
+
*/
|
|
98
|
+
function rolledWindow(stored, horizon) {
|
|
99
|
+
if (stored.start <= horizon.start && stored.end >= horizon.end)
|
|
100
|
+
return null;
|
|
101
|
+
if (stored.end.getTime() - horizon.start.getTime() > MAX_CALENDAR_WINDOW_MS)
|
|
102
|
+
return null;
|
|
103
|
+
return { start: horizon.start, end: stored.end > horizon.end ? stored.end : horizon.end };
|
|
104
|
+
}
|
|
92
105
|
/** The roll's `after`, checked, or `undefined` for a pass's first batch. */
|
|
93
106
|
function rollCursor(after) {
|
|
94
107
|
if (after === undefined)
|
|
@@ -174,11 +187,6 @@ function isFiniteWindow(window) {
|
|
|
174
187
|
Number.isFinite(window.end.getTime()) &&
|
|
175
188
|
window.end.getTime() > window.start.getTime());
|
|
176
189
|
}
|
|
177
|
-
function invalidWindowError() {
|
|
178
|
-
return Object.assign(new Error('Slotlock window must contain two finite dates with end > start'), {
|
|
179
|
-
code: 'invalid_window',
|
|
180
|
-
});
|
|
181
|
-
}
|
|
182
190
|
function isValidIdentity(value) {
|
|
183
191
|
return value !== undefined && value.trim().length > 0 && Buffer.byteLength(value, 'utf8') <= 500;
|
|
184
192
|
}
|
|
@@ -721,6 +729,18 @@ async function overlapLoss(sql, resourceId, start, end, bufferAfterMs = 0, exclu
|
|
|
721
729
|
LIMIT 1`;
|
|
722
730
|
return { ok: false, code: 'overlap', conflictingReservationId: winners[0]?.id ?? null };
|
|
723
731
|
}
|
|
732
|
+
/** The tenant a hold call is limited to, or null for none; an invalid one is refused. */
|
|
733
|
+
function holdTenantRef(options) {
|
|
734
|
+
const tenantRef = options.tenantRef;
|
|
735
|
+
if (tenantRef === undefined)
|
|
736
|
+
return null;
|
|
737
|
+
if (!isValidIdentity(tenantRef)) {
|
|
738
|
+
throw Object.assign(new Error('Slotlock tenantRef must be 1-500 UTF-8 bytes'), {
|
|
739
|
+
code: 'invalid_tenant_ref',
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
return tenantRef;
|
|
743
|
+
}
|
|
724
744
|
export function createSlotlockStore(sql, options = {}) {
|
|
725
745
|
const configuredTenantSetting = options.tenantContextSetting ?? SLOTLOCK_TENANT_CONTEXT_SETTING;
|
|
726
746
|
const configuredAgentOwnerEventQuota = options.agentOwnerEventQuota ?? SLOTLOCK_AGENT_OWNER_EVENT_QUOTA;
|
|
@@ -1077,14 +1097,22 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
1077
1097
|
WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${params.externalRef}`;
|
|
1078
1098
|
if (cancelled[0])
|
|
1079
1099
|
return { ok: false, code: 'reservation_cancelled' };
|
|
1100
|
+
// A calendar event's occupation carries a reference in this namespace too, but it is
|
|
1101
|
+
// not a booking: create, reschedule and cancel never read, replay, move or free it,
|
|
1102
|
+
// and answer as for a reference no booking holds. Bookings have their own unique index.
|
|
1080
1103
|
const existing = await tx `
|
|
1081
1104
|
SELECT id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
|
|
1082
1105
|
buffer_after_ms, revision, source
|
|
1083
1106
|
FROM slotlock.reservations
|
|
1084
1107
|
WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${params.externalRef}
|
|
1108
|
+
AND calendar_event_id IS NULL
|
|
1085
1109
|
FOR UPDATE`;
|
|
1086
1110
|
const found = existing[0];
|
|
1087
1111
|
if (found) {
|
|
1112
|
+
// The booking has moved on since: a delayed create is stale, as a delayed move is.
|
|
1113
|
+
if (params.revision < Number(found.revision)) {
|
|
1114
|
+
return { ok: false, code: 'stale_revision' };
|
|
1115
|
+
}
|
|
1088
1116
|
if (!sameExternalPayload(found, params)) {
|
|
1089
1117
|
return { ok: false, code: 'idempotency_conflict' };
|
|
1090
1118
|
}
|
|
@@ -1138,6 +1166,7 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
1138
1166
|
buffer_after_ms, revision, source
|
|
1139
1167
|
FROM slotlock.reservations
|
|
1140
1168
|
WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${params.externalRef}
|
|
1169
|
+
AND calendar_event_id IS NULL
|
|
1141
1170
|
FOR UPDATE`;
|
|
1142
1171
|
const found = existing[0];
|
|
1143
1172
|
if (!found) {
|
|
@@ -1222,6 +1251,7 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
1222
1251
|
const existing = await tx `
|
|
1223
1252
|
SELECT id, revision FROM slotlock.reservations
|
|
1224
1253
|
WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${params.externalRef}
|
|
1254
|
+
AND calendar_event_id IS NULL
|
|
1225
1255
|
FOR UPDATE`;
|
|
1226
1256
|
const found = existing[0];
|
|
1227
1257
|
if (found) {
|
|
@@ -1294,11 +1324,14 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
1294
1324
|
}
|
|
1295
1325
|
}
|
|
1296
1326
|
},
|
|
1297
|
-
async confirmHold(holdId) {
|
|
1327
|
+
async confirmHold(holdId, options = {}) {
|
|
1328
|
+
const tenantRef = holdTenantRef(options);
|
|
1298
1329
|
const rows = await sql `
|
|
1299
1330
|
UPDATE slotlock.reservations
|
|
1300
1331
|
SET status = 'confirmed', expires_at = NULL
|
|
1301
1332
|
WHERE id = ${holdId} AND status = 'held' AND expires_at > now()
|
|
1333
|
+
AND (${tenantRef}::text IS NULL
|
|
1334
|
+
OR resource_id IN (SELECT id FROM slotlock.resources WHERE tenant_ref = ${tenantRef}))
|
|
1302
1335
|
RETURNING id, resource_id, starts_at, ends_at, source`;
|
|
1303
1336
|
const row = rows[0];
|
|
1304
1337
|
if (row) {
|
|
@@ -1320,7 +1353,13 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
1320
1353
|
const present = await sql `
|
|
1321
1354
|
SELECT status, (status = 'held' AND expires_at <= now()) AS expired,
|
|
1322
1355
|
resource_id, starts_at, ends_at, source
|
|
1323
|
-
FROM slotlock.reservations
|
|
1356
|
+
FROM slotlock.reservations
|
|
1357
|
+
WHERE id = ${holdId}
|
|
1358
|
+
-- A hold has no external identity, before or after it is confirmed; a booking that has
|
|
1359
|
+
-- one is not a hold, and confirming it again would describe it wrongly.
|
|
1360
|
+
AND tenant_ref IS NULL
|
|
1361
|
+
AND (${tenantRef}::text IS NULL
|
|
1362
|
+
OR resource_id IN (SELECT id FROM slotlock.resources WHERE tenant_ref = ${tenantRef}))`;
|
|
1324
1363
|
const found = present[0];
|
|
1325
1364
|
// Idempotent confirm: a retried confirm (network replay) of an already-confirmed hold is
|
|
1326
1365
|
// SUCCESS — the booking exists; reporting failure would make callers double-book elsewhere.
|
|
@@ -1347,10 +1386,13 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
1347
1386
|
// taken; both refusal codes are terminal for the caller.
|
|
1348
1387
|
return { ok: false, code: 'hold_not_found' };
|
|
1349
1388
|
},
|
|
1350
|
-
async releaseHold(holdId) {
|
|
1389
|
+
async releaseHold(holdId, options = {}) {
|
|
1390
|
+
const tenantRef = holdTenantRef(options);
|
|
1351
1391
|
const rows = await sql `
|
|
1352
1392
|
DELETE FROM slotlock.reservations
|
|
1353
1393
|
WHERE id = ${holdId} AND status = 'held'
|
|
1394
|
+
AND (${tenantRef}::text IS NULL
|
|
1395
|
+
OR resource_id IN (SELECT id FROM slotlock.resources WHERE tenant_ref = ${tenantRef}))
|
|
1354
1396
|
RETURNING id`;
|
|
1355
1397
|
return { ok: true, released: rows.length > 0 };
|
|
1356
1398
|
},
|
|
@@ -1704,15 +1746,18 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
1704
1746
|
});
|
|
1705
1747
|
}
|
|
1706
1748
|
const after = rollCursor(params.after);
|
|
1749
|
+
// Due: the masters rolledWindow rolls, which do not cover the window and end within the cap
|
|
1750
|
+
// of its start.
|
|
1707
1751
|
const candidates = await sql `
|
|
1708
1752
|
SELECT id, materialized_ends_at
|
|
1709
1753
|
FROM slotlock.calendar_events
|
|
1710
1754
|
WHERE tenant_ref = ${params.tenantRef}
|
|
1711
1755
|
AND recurrence_rule IS NOT NULL
|
|
1712
1756
|
AND (
|
|
1713
|
-
materialized_starts_at
|
|
1757
|
+
materialized_starts_at > ${window.start}
|
|
1714
1758
|
OR materialized_ends_at < ${window.end}
|
|
1715
1759
|
)
|
|
1760
|
+
AND materialized_ends_at <= ${new Date(window.start.getTime() + MAX_CALENDAR_WINDOW_MS)}
|
|
1716
1761
|
AND (
|
|
1717
1762
|
${after?.materializedEndsAt ?? null}::timestamptz IS NULL
|
|
1718
1763
|
OR (materialized_ends_at, id) >
|
|
@@ -1738,16 +1783,15 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
1738
1783
|
FROM slotlock.calendar_events
|
|
1739
1784
|
WHERE id = ${candidate.id} AND tenant_ref = ${params.tenantRef}
|
|
1740
1785
|
FOR UPDATE`;
|
|
1741
|
-
if (!row ||
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1786
|
+
if (!row || row.recurrence_rule === null)
|
|
1787
|
+
return false;
|
|
1788
|
+
const rolled = rolledWindow({ start: row.materialized_starts_at, end: row.materialized_ends_at }, window);
|
|
1789
|
+
if (!rolled)
|
|
1745
1790
|
return false;
|
|
1746
|
-
}
|
|
1747
1791
|
const childMap = await loadCalendarEventChildren(tx, [row.id]);
|
|
1748
1792
|
const event = toCalendarEvent(row, childMap.get(row.id));
|
|
1749
1793
|
// From where its last expansion stopped, when that can be trusted; else its start.
|
|
1750
|
-
const { occurrences, cursor } = expandCalendarEventSeries(event,
|
|
1794
|
+
const { occurrences, cursor } = expandCalendarEventSeries(event, rolled, {
|
|
1751
1795
|
cursor: row.recurrence_cursor ?? null,
|
|
1752
1796
|
stored: true,
|
|
1753
1797
|
});
|
|
@@ -1761,8 +1805,8 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
1761
1805
|
});
|
|
1762
1806
|
await tx `
|
|
1763
1807
|
UPDATE slotlock.calendar_events
|
|
1764
|
-
SET materialized_starts_at = ${
|
|
1765
|
-
materialized_ends_at = ${
|
|
1808
|
+
SET materialized_starts_at = ${rolled.start},
|
|
1809
|
+
materialized_ends_at = ${rolled.end},
|
|
1766
1810
|
recurrence_cursor = ${seriesCursorJson(tx, cursor)}
|
|
1767
1811
|
WHERE id = ${row.id} AND tenant_ref = ${params.tenantRef}`;
|
|
1768
1812
|
return true;
|
|
@@ -2157,19 +2201,16 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
2157
2201
|
AND event.transparency = 'opaque'
|
|
2158
2202
|
)
|
|
2159
2203
|
SELECT count(*)::int AS source_count,
|
|
2204
|
+
-- Complete when what is stored runs from where the series can first occur in the
|
|
2205
|
+
-- window (its start, if that is later) to the window's end: a series stored only
|
|
2206
|
+
-- from after its start leaves the occurrences before that unknown.
|
|
2160
2207
|
bool_and(
|
|
2161
2208
|
materialized_ends_at >= ${window.end}
|
|
2162
|
-
AND (
|
|
2163
|
-
earliest_possible_at >= ${window.start}
|
|
2164
|
-
OR materialized_starts_at <= ${window.start}
|
|
2165
|
-
)
|
|
2209
|
+
AND materialized_starts_at <= GREATEST(earliest_possible_at, ${window.start})
|
|
2166
2210
|
) AS complete,
|
|
2167
2211
|
bool_or(
|
|
2168
2212
|
materialized_ends_at > ${window.start}
|
|
2169
|
-
AND
|
|
2170
|
-
earliest_possible_at >= ${window.start}
|
|
2171
|
-
OR materialized_starts_at < ${window.end}
|
|
2172
|
-
)
|
|
2213
|
+
AND materialized_starts_at < ${window.end}
|
|
2173
2214
|
) AS overlaps
|
|
2174
2215
|
FROM recurring_sources
|
|
2175
2216
|
WHERE earliest_possible_at < ${window.end}`;
|
|
@@ -2222,6 +2263,7 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
2222
2263
|
async findNextAvailableFor(params) {
|
|
2223
2264
|
if (!isFiniteWindow(params.searchWindow))
|
|
2224
2265
|
throw invalidWindowError();
|
|
2266
|
+
assertBoundedSearchWindow(params.searchWindow);
|
|
2225
2267
|
if (!Number.isFinite(params.durationMs) || params.durationMs <= 0)
|
|
2226
2268
|
return null;
|
|
2227
2269
|
// Rules are authored in the resource's zone — expansion must honour it (DST-correct).
|