slotlock 0.5.3 → 0.6.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 +101 -5
- package/README.md +65 -51
- package/SECURITY.md +26 -13
- package/agent-server.d.ts +9 -0
- package/agent-server.js +29 -3
- package/application-api.js +6 -3
- package/ddl.d.ts +20 -3
- package/ddl.js +164 -7
- package/event-feed.d.ts +25 -8
- package/event-feed.js +45 -14
- package/index.d.ts +1 -1
- package/openapi.json +27 -2
- package/package.json +1 -1
- package/rest-api.js +12 -0
- package/self-host.js +50 -2
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +4 -1
- package/store.js +133 -21
- package/webhooks.d.ts +3 -2
- package/webhooks.js +6 -3
package/store.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// DB mechanics for the Slotlock core — explicit `postgres` client in, structured results out.
|
|
2
2
|
// Domain policy (who may reserve, pricing, holds) belongs to future callers; this layer owns the
|
|
3
3
|
// reliable how: idempotent schema apply, EXCLUDE-arbitrated inserts with a structured conflict.
|
|
4
|
-
import { SLOTLOCK_CHANGE_ORIGINS, SLOTLOCK_CHANGE_RETENTION_DAYS, isSlotlockChangeOrigin,
|
|
4
|
+
import { SLOTLOCK_CHANGE_ORIGINS, SLOTLOCK_CHANGE_RETENTION_DAYS, isSlotlockChangeOrigin, slotlockEventFeedMethods, } from './event-feed.js';
|
|
5
5
|
import { bookingBufferBeforeMs, bookingSearchBlocked, bookingSearchReach, parseBookingRuleDefaults, parseEffectiveBookingRules, bookingBufferAfterMs, bookingRuleBroken, bookingSearch, parseBookingRules, } from './booking-rules.js';
|
|
6
6
|
import { createHash, randomUUID } from 'node:crypto';
|
|
7
7
|
import ICAL from 'ical.js';
|
|
8
|
-
import { SLOTLOCK_CORE_DDL, SLOTLOCK_DEPLOYMENT_CHECKS, SLOTLOCK_SCHEMA_CONTROL_REFUSAL, SLOTLOCK_SCHEMA_NEWER_REFUSAL, SLOTLOCK_SCHEMA_VERSION, SLOTLOCK_STRUCTURE_DDL, SLOTLOCK_TENANT_CONTEXT_SETTING, SLOTLOCK_TENANT_TABLES, SLOTLOCK_VALIDATION_DDL, createSlotlockApplicationRoleGrantsDdl, createSlotlockTenantRlsDdl, slotlockTenantLockKey, } from './ddl.js';
|
|
8
|
+
import { SLOTLOCK_CORE_DDL, SLOTLOCK_COVERAGE_CHANGE_RENAME_BATCH, SLOTLOCK_DEPLOYMENT_CHECKS, SLOTLOCK_SCHEMA_CONTROL_REFUSAL, SLOTLOCK_SCHEMA_NEWER_REFUSAL, SLOTLOCK_SCHEMA_VERSION, SLOTLOCK_STRUCTURE_DDL, SLOTLOCK_TENANT_CONTEXT_SETTING, SLOTLOCK_TENANT_TABLES, SLOTLOCK_VALIDATION_DDL, createSlotlockApplicationRoleGrantsDdl, createSlotlockTenantRlsDdl, slotlockCoverageChangeRenameSql, slotlockTenantLockKey, } from './ddl.js';
|
|
9
9
|
import { findNextAvailable, mergeIntervals } from './engine.js';
|
|
10
10
|
import { SLOTLOCK_CALENDAR_HORIZON_DAYS, assertBoundedSearchWindow, expandRules, invalidWindowError, } from './rules.js';
|
|
11
11
|
import { CalendarContractError, CalendarExpansionLimitError, expandCalendarEventSeries, normalizeCalendarEventContent, } from './sync.js';
|
|
@@ -82,8 +82,6 @@ const MAX_CONFIGURED_AGENT_OWNER_EVENT_QUOTA = 100_000;
|
|
|
82
82
|
const MAX_CONFIGURED_AGENT_OWNER_COMMAND_QUOTA = 1_000_000;
|
|
83
83
|
const SCHEMA_APPLY_LOCK = 'slotlock:schema-apply:v1';
|
|
84
84
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
85
|
-
// A coverage source as the feed names it (coverage.stale): its digest, never the source itself.
|
|
86
|
-
const COVERAGE_SOURCE_ID = slotlockCoverageSourceIdSql('tenant_ref', 'source');
|
|
87
85
|
const AGENT_EVENT_OWNER_PATTERN = /^agent:[0-9a-f]{64}$/;
|
|
88
86
|
/** Store-owned coverage source for bounded, locally materialized recurring events. */
|
|
89
87
|
export const SLOTLOCK_LOCAL_RECURRENCE_COVERAGE_SOURCE = 'slotlock:local-recurrence-materialization';
|
|
@@ -345,8 +343,8 @@ function migrationSettings(options) {
|
|
|
345
343
|
/**
|
|
346
344
|
* Run one migration step: in a transaction (or a savepoint of the caller's), with names resolved
|
|
347
345
|
* in pg_catalog only, serialized with every other migration of this database, and with the lock and
|
|
348
|
-
* statement timeouts set. A step that waits past its lock timeout is
|
|
349
|
-
* after a pause, up to `attempts` times. The caller's own timeouts are put back afterwards.
|
|
346
|
+
* statement timeouts set. A step that waits past its lock timeout, or is ended as a deadlock's
|
|
347
|
+
* victim, is rolled back and tried again after a pause, up to `attempts` times. The caller's own timeouts are put back afterwards.
|
|
350
348
|
*/
|
|
351
349
|
async function inMigration(executor, options, step) {
|
|
352
350
|
const settings = migrationSettings(options);
|
|
@@ -369,10 +367,14 @@ async function inMigration(executor, options, step) {
|
|
|
369
367
|
}));
|
|
370
368
|
}
|
|
371
369
|
catch (error) {
|
|
372
|
-
|
|
370
|
+
// A deadlock's victim (DEADLOCK_DETECTED: a caller's transaction took the outbox, then a
|
|
371
|
+
// coverage row, while a rename batch took them in the other order, say) lost its locks with
|
|
372
|
+
// it, so it is tried again like a step that timed out waiting.
|
|
373
|
+
const code = pgCode(error);
|
|
374
|
+
if (code !== LOCK_NOT_AVAILABLE && code !== DEADLOCK_DETECTED)
|
|
373
375
|
throw error;
|
|
374
376
|
if (attempt >= settings.attempts) {
|
|
375
|
-
throw Object.assign(new Error(`Slotlock migration gave up after ${attempt} attempts to take its locks within ${settings.lockTimeoutMs} ms: a long transaction holds a slotlock table. Run it again once that transaction ends`, { cause: error }), { code: 'schema_lock_timeout' });
|
|
377
|
+
throw Object.assign(new Error(`Slotlock migration gave up after ${attempt} attempts to take its locks within ${settings.lockTimeoutMs} ms: a long transaction holds a slotlock table, or took them in another order. Run it again once that transaction ends`, { cause: error }), { code: 'schema_lock_timeout' });
|
|
376
378
|
}
|
|
377
379
|
await new Promise((resolve) => setTimeout(resolve, Math.min(attempt * 250, 2_000) + Math.floor(Math.random() * 250)));
|
|
378
380
|
}
|
|
@@ -472,6 +474,105 @@ async function slotlockSchemaUpToDate(tx) {
|
|
|
472
474
|
}
|
|
473
475
|
return recorded.catalog_sha256 === (await slotlockCatalogDigest(tx));
|
|
474
476
|
}
|
|
477
|
+
/**
|
|
478
|
+
* Rename the coverage changes an earlier release wrote (ddl.ts, slotlockCoverageChangeRenameSql),
|
|
479
|
+
* in batches of SLOTLOCK_COVERAGE_CHANGE_RENAME_BATCH outbox ids, each a transaction of its own,
|
|
480
|
+
* from where the last pass stopped (`schema_version.coverage_changes_renamed_through`, saved with
|
|
481
|
+
* each batch) up to the last change when this pass starts. The first pass is the 0.6 upgrade's; an
|
|
482
|
+
* earlier release's server still running then can write more, so every later migration runs a pass
|
|
483
|
+
* too, until one finds nothing to rename: that marks the rename done and no migration reads the
|
|
484
|
+
* outbox for it again.
|
|
485
|
+
*
|
|
486
|
+
* The head is read only once every transaction already writing to the outbox has ended, so no
|
|
487
|
+
* change at or below it can commit after the pass has read past it. Forced RLS would hide every
|
|
488
|
+
* tenant's rows from the deployment role, so a role it binds lifts FORCE from calendar_coverage and
|
|
489
|
+
* outbox for each batch and puts it back before the batch commits: each batch holds ACCESS
|
|
490
|
+
* EXCLUSIVE on both, taken in the order writers take them (a source, then its change), for one
|
|
491
|
+
* primary-key range of the outbox. A table an operator left unforced stays so. A superuser or
|
|
492
|
+
* BYPASSRLS role reads every row as it is: it holds SHARE ROW EXCLUSIVE on the outbox (which waits
|
|
493
|
+
* for the writes in flight and holds off new ones, but no read) only for the short transaction that
|
|
494
|
+
* reads the head. Changes an earlier release's server writes after the upgrade are put in shape as
|
|
495
|
+
* they are inserted (ddl.ts, slotlock.outbox_coverage_in_shape); the passes are for those before.
|
|
496
|
+
*/
|
|
497
|
+
async function renameEarlierCoverageChanges(executor, options) {
|
|
498
|
+
/**
|
|
499
|
+
* Run `work` able to read every tenant's outbox and coverage rows: as it is for a superuser or a
|
|
500
|
+
* BYPASSRLS role, else with FORCE lifted from the tables that have it, under ACCESS EXCLUSIVE
|
|
501
|
+
* (calendar_coverage, then outbox: the order every writer locks them in), and put back after.
|
|
502
|
+
*/
|
|
503
|
+
const everyTenant = async (tx, bypass, work) => {
|
|
504
|
+
const forced = bypass
|
|
505
|
+
? []
|
|
506
|
+
: await tx `
|
|
507
|
+
SELECT rel.relname::text AS name
|
|
508
|
+
FROM pg_catalog.pg_class rel
|
|
509
|
+
JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace
|
|
510
|
+
WHERE ns.nspname = 'slotlock' AND rel.relname IN ('calendar_coverage', 'outbox')
|
|
511
|
+
AND rel.relforcerowsecurity
|
|
512
|
+
ORDER BY rel.relname`;
|
|
513
|
+
const lifted = forced.map(({ name }) => `slotlock.${name}`);
|
|
514
|
+
if (lifted.length > 0) {
|
|
515
|
+
await tx.unsafe(`LOCK TABLE ${lifted.join(', ')} IN ACCESS EXCLUSIVE MODE`);
|
|
516
|
+
for (const table of lifted)
|
|
517
|
+
await tx.unsafe(`ALTER TABLE ${table} NO FORCE ROW LEVEL SECURITY`);
|
|
518
|
+
}
|
|
519
|
+
const result = await work();
|
|
520
|
+
for (const table of lifted)
|
|
521
|
+
await tx.unsafe(`ALTER TABLE ${table} FORCE ROW LEVEL SECURITY`);
|
|
522
|
+
return result;
|
|
523
|
+
};
|
|
524
|
+
// The pass's head, in a short transaction of its own: once the writes in flight have ended (each
|
|
525
|
+
// holds ROW EXCLUSIVE from before it took its id), none at or below it can commit later.
|
|
526
|
+
const start = await inMigration(executor, options, async (tx) => {
|
|
527
|
+
const [state] = await tx `
|
|
528
|
+
SELECT version.coverage_changes_renamed AS renamed,
|
|
529
|
+
version.coverage_changes_renamed_through::text AS through,
|
|
530
|
+
EXISTS (SELECT 1 FROM pg_catalog.pg_roles
|
|
531
|
+
WHERE rolname = current_user AND (rolsuper OR rolbypassrls)) AS bypass
|
|
532
|
+
FROM slotlock.schema_version version
|
|
533
|
+
WHERE version.singleton`;
|
|
534
|
+
if (state === undefined || state.renamed)
|
|
535
|
+
return null;
|
|
536
|
+
if (state.bypass)
|
|
537
|
+
await tx.unsafe('LOCK TABLE slotlock.outbox IN SHARE ROW EXCLUSIVE MODE');
|
|
538
|
+
const [top] = await everyTenant(tx, state.bypass, () => tx `SELECT COALESCE(max(id), 0)::text AS head FROM slotlock.outbox`);
|
|
539
|
+
return { head: top?.head ?? '0', through: state.through, bypass: state.bypass };
|
|
540
|
+
});
|
|
541
|
+
if (start === null)
|
|
542
|
+
return;
|
|
543
|
+
const { head, bypass } = start;
|
|
544
|
+
const firstPass = start.through === null;
|
|
545
|
+
const rename = slotlockCoverageChangeRenameSql('$1::bigint', '$2::bigint', '$3::int');
|
|
546
|
+
let after = start.through ?? '0';
|
|
547
|
+
let renamedTotal = 0;
|
|
548
|
+
for (;;) {
|
|
549
|
+
const last = await inMigration(executor, options, async (tx) => {
|
|
550
|
+
const [row] = await everyTenant(tx, bypass, () => tx.unsafe(rename, [
|
|
551
|
+
after,
|
|
552
|
+
head,
|
|
553
|
+
SLOTLOCK_COVERAGE_CHANGE_RENAME_BATCH,
|
|
554
|
+
]));
|
|
555
|
+
const read = row?.last ?? null;
|
|
556
|
+
renamedTotal += row?.renamed ?? 0;
|
|
557
|
+
// Saved with the batch, so a pass stopped part way goes on from here.
|
|
558
|
+
await tx `
|
|
559
|
+
UPDATE slotlock.schema_version
|
|
560
|
+
SET coverage_changes_renamed_through = ${read ?? head}::bigint,
|
|
561
|
+
coverage_changes_renamed = ${read === null && !firstPass && renamedTotal === 0}
|
|
562
|
+
WHERE singleton`;
|
|
563
|
+
return read;
|
|
564
|
+
});
|
|
565
|
+
if (last === null)
|
|
566
|
+
return;
|
|
567
|
+
after = String(last);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
/** Whether the rename of coverage changes earlier releases wrote still has a pass to run. */
|
|
571
|
+
async function coverageChangesRenamePending(tx) {
|
|
572
|
+
const [row] = await tx `
|
|
573
|
+
SELECT coverage_changes_renamed AS renamed FROM slotlock.schema_version WHERE singleton`;
|
|
574
|
+
return row !== undefined && !row.renamed;
|
|
575
|
+
}
|
|
475
576
|
/** Whether a constraint in the `slotlock` schema is still NOT VALID (old rows broke it). */
|
|
476
577
|
async function slotlockConstraintUnvalidated(tx) {
|
|
477
578
|
const [row] = await tx `
|
|
@@ -2050,19 +2151,30 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2050
2151
|
// application instances can still collide on pg_namespace/pg_extension unique indexes.
|
|
2051
2152
|
// inMigration serializes bootstrap per database and the transaction releases the lock on
|
|
2052
2153
|
// crashes. A schema this release already left as it is changes nothing and locks no table,
|
|
2053
|
-
// unless old rows kept one of its constraints NOT VALID
|
|
2054
|
-
|
|
2154
|
+
// unless old rows kept one of its constraints NOT VALID (then only the validation runs again)
|
|
2155
|
+
// or the rename of earlier coverage changes is not done (then only a pass of it runs).
|
|
2156
|
+
const next = await inMigration(sql, migrationOptions, async (tx) => {
|
|
2055
2157
|
await tx.unsafe(SLOTLOCK_DEPLOYMENT_CHECKS).catch((error) => {
|
|
2056
2158
|
throw schemaControlRefusal(error);
|
|
2057
2159
|
});
|
|
2058
|
-
if (await slotlockSchemaUpToDate(tx))
|
|
2059
|
-
return
|
|
2160
|
+
if (await slotlockSchemaUpToDate(tx)) {
|
|
2161
|
+
return {
|
|
2162
|
+
validate: await slotlockConstraintUnvalidated(tx),
|
|
2163
|
+
rename: await coverageChangesRenamePending(tx),
|
|
2164
|
+
};
|
|
2165
|
+
}
|
|
2060
2166
|
await tx.unsafe(SLOTLOCK_STRUCTURE_DDL).catch((error) => {
|
|
2061
2167
|
throw schemaControlRefusal(error);
|
|
2062
2168
|
});
|
|
2063
|
-
return true;
|
|
2169
|
+
return { validate: true, rename: await coverageChangesRenamePending(tx) };
|
|
2064
2170
|
});
|
|
2065
|
-
|
|
2171
|
+
// Changes an earlier release wrote are renamed in short batches once the structure commits,
|
|
2172
|
+
// never under its locks, by this migration and the next ones until a pass finds none
|
|
2173
|
+
// (renameEarlierCoverageChanges); the feed serves any not renamed yet redacted (event-feed.ts,
|
|
2174
|
+
// slotlockServedChange).
|
|
2175
|
+
if (next.rename)
|
|
2176
|
+
await renameEarlierCoverageChanges(sql, migrationOptions);
|
|
2177
|
+
if (!next.validate)
|
|
2066
2178
|
return;
|
|
2067
2179
|
// Once the DDL has committed, its ACCESS EXCLUSIVE locks are gone: validating holds none that
|
|
2068
2180
|
// a serving query waits for (ddl.ts, DEPLOYMENT_VALIDATE_CONSTRAINTS). Inside a caller's
|
|
@@ -4476,14 +4588,14 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4476
4588
|
}
|
|
4477
4589
|
const maxAgeMs = params.maxAgeMs ?? 24 * 60 * 60 * 1000;
|
|
4478
4590
|
const rows = await sql `
|
|
4479
|
-
SELECT source, starts_at, ends_at, revision, observed_at, last_error, last_error_at,
|
|
4591
|
+
SELECT source, source_id, starts_at, ends_at, revision, observed_at, last_error, last_error_at,
|
|
4480
4592
|
observed_at < now() - make_interval(secs => ${maxAgeMs / 1_000}) AS stale
|
|
4481
4593
|
FROM slotlock.calendar_coverage
|
|
4482
4594
|
WHERE tenant_ref = ${params.tenantRef} AND resource_id = ${params.resourceId}
|
|
4483
4595
|
ORDER BY source`;
|
|
4484
4596
|
return rows.map((row) => ({
|
|
4485
4597
|
source: row.source,
|
|
4486
|
-
sourceId:
|
|
4598
|
+
sourceId: row.source_id,
|
|
4487
4599
|
start: row.starts_at,
|
|
4488
4600
|
end: row.ends_at,
|
|
4489
4601
|
revision: Number(row.revision),
|
|
@@ -4507,15 +4619,15 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4507
4619
|
AND observed_at < now() - make_interval(secs => ${maxAgeMs / 1_000})
|
|
4508
4620
|
ORDER BY observed_at LIMIT ${params.limit ?? 100}
|
|
4509
4621
|
FOR UPDATE SKIP LOCKED)
|
|
4510
|
-
RETURNING coverage.
|
|
4622
|
+
RETURNING coverage.resource_id, coverage.source_id, coverage.observed_at
|
|
4511
4623
|
), reported AS (
|
|
4512
4624
|
INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
|
|
4513
|
-
SELECT ${params.tenantRef},
|
|
4625
|
+
SELECT ${params.tenantRef}, stale.resource_id, 'coverage.stale', stale.source_id,
|
|
4514
4626
|
jsonb_build_object(
|
|
4515
|
-
'resource_id',
|
|
4516
|
-
'observed_at',
|
|
4627
|
+
'resource_id', stale.resource_id, 'source_id', stale.source_id,
|
|
4628
|
+
'observed_at', stale.observed_at),
|
|
4517
4629
|
'slotlock:maintenance'
|
|
4518
|
-
FROM
|
|
4630
|
+
FROM stale
|
|
4519
4631
|
RETURNING 1
|
|
4520
4632
|
)
|
|
4521
4633
|
SELECT count(*)::int AS count FROM reported`;
|
package/webhooks.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { lookup as dnsLookup } from 'node:dns';
|
|
2
|
-
import type
|
|
2
|
+
import { type SlotlockChange, type SlotlockChangeOrigin, type SlotlockEventFeed } from './event-feed.js';
|
|
3
3
|
/** The CloudEvents envelope a change is delivered and listed as. */
|
|
4
4
|
export interface SlotlockCloudEvent {
|
|
5
5
|
specversion: '1.0';
|
|
@@ -17,7 +17,8 @@ export interface SlotlockCloudEvent {
|
|
|
17
17
|
}
|
|
18
18
|
/**
|
|
19
19
|
* A change as CloudEvents 1.0 (structured mode): `type` is `io.pylota.slotlock.<change type>`, with
|
|
20
|
-
* the extension attributes `slotlockorigin` and `slotlockkeyid` when the change records them.
|
|
20
|
+
* the extension attributes `slotlockorigin` and `slotlockkeyid` when the change records them. A
|
|
21
|
+
* `coverage.*` change is redacted as the feed serves it (slotlockServedChange), whoever built it.
|
|
21
22
|
*/
|
|
22
23
|
export declare function slotlockCloudEvent(change: SlotlockChange): SlotlockCloudEvent;
|
|
23
24
|
/**
|
package/webhooks.js
CHANGED
|
@@ -9,23 +9,26 @@ import { Agent as HttpAgent, request as httpRequest } from 'node:http';
|
|
|
9
9
|
import { Agent as HttpsAgent, request as httpsRequest } from 'node:https';
|
|
10
10
|
import { BlockList, isIP } from 'node:net';
|
|
11
11
|
import { Readable } from 'node:stream';
|
|
12
|
+
import { slotlockServedChange, } from './event-feed.js';
|
|
12
13
|
import { ignoreRejection } from './hooks.js';
|
|
13
14
|
/**
|
|
14
15
|
* A change as CloudEvents 1.0 (structured mode): `type` is `io.pylota.slotlock.<change type>`, with
|
|
15
|
-
* the extension attributes `slotlockorigin` and `slotlockkeyid` when the change records them.
|
|
16
|
+
* the extension attributes `slotlockorigin` and `slotlockkeyid` when the change records them. A
|
|
17
|
+
* `coverage.*` change is redacted as the feed serves it (slotlockServedChange), whoever built it.
|
|
16
18
|
*/
|
|
17
19
|
export function slotlockCloudEvent(change) {
|
|
20
|
+
const served = slotlockServedChange(change.type, change.subject, change.data);
|
|
18
21
|
return {
|
|
19
22
|
specversion: '1.0',
|
|
20
23
|
id: change.id,
|
|
21
24
|
source: change.resourceId ? `/slotlock/resources/${change.resourceId}` : '/slotlock',
|
|
22
25
|
type: `io.pylota.slotlock.${change.type}`,
|
|
23
|
-
subject:
|
|
26
|
+
subject: served.subject,
|
|
24
27
|
time: change.occurredAt.toISOString(),
|
|
25
28
|
datacontenttype: 'application/json',
|
|
26
29
|
...(change.origin ? { slotlockorigin: change.origin } : {}),
|
|
27
30
|
...(change.keyId ? { slotlockkeyid: change.keyId } : {}),
|
|
28
|
-
data:
|
|
31
|
+
data: served.data,
|
|
29
32
|
};
|
|
30
33
|
}
|
|
31
34
|
/**
|