@hasna/todos 0.15.46 → 0.15.49
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/dashboard/dist/assets/index-BNQ4gJua.js +342 -0
- package/dashboard/dist/assets/index-DjzvHUWt.css +1 -0
- package/dashboard/dist/index.html +2 -2
- package/dist/cli/commands/query-commands.d.ts.map +1 -1
- package/dist/cli/components/Dashboard.d.ts +1 -1
- package/dist/cli/components/Dashboard.d.ts.map +1 -1
- package/dist/cli/components/Header.d.ts +1 -1
- package/dist/cli/components/Header.d.ts.map +1 -1
- package/dist/cli/components/ProjectList.d.ts +1 -1
- package/dist/cli/components/ProjectList.d.ts.map +1 -1
- package/dist/cli/components/SearchView.d.ts +1 -1
- package/dist/cli/components/SearchView.d.ts.map +1 -1
- package/dist/cli/components/TaskDetail.d.ts +1 -1
- package/dist/cli/components/TaskDetail.d.ts.map +1 -1
- package/dist/cli/components/TaskForm.d.ts +1 -1
- package/dist/cli/components/TaskForm.d.ts.map +1 -1
- package/dist/cli/components/TaskList.d.ts +1 -1
- package/dist/cli/components/TaskList.d.ts.map +1 -1
- package/dist/cli/index.js +671 -155
- package/dist/contracts.js +396 -82
- package/dist/db/task-crud.d.ts +6 -0
- package/dist/db/task-crud.d.ts.map +1 -1
- package/dist/db/tasks.d.ts +1 -1
- package/dist/db/tasks.d.ts.map +1 -1
- package/dist/db/webhooks.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +561 -143
- package/dist/lib/dedupe-projection.d.ts +48 -0
- package/dist/lib/dedupe-projection.d.ts.map +1 -0
- package/dist/lib/redaction.d.ts.map +1 -1
- package/dist/lib/task-dedupe.d.ts +5 -9
- package/dist/lib/task-dedupe.d.ts.map +1 -1
- package/dist/mcp/index.js +621 -154
- package/dist/mcp/tools/task-crud.d.ts.map +1 -1
- package/dist/mcp.js +6 -4
- package/dist/project-registration.js +502 -141
- package/dist/registry.js +396 -82
- package/dist/release-provenance.json +5 -5
- package/dist/server/cloud.d.ts.map +1 -1
- package/dist/server/index.js +1278 -342
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/v1.d.ts.map +1 -1
- package/dist/storage/postgres-adapter.d.ts.map +1 -1
- package/dist/storage/postgres-errors.d.ts +15 -0
- package/dist/storage/postgres-errors.d.ts.map +1 -0
- package/dist/storage/postgres-sync.d.ts +7 -0
- package/dist/storage/postgres-sync.d.ts.map +1 -1
- package/dist/storage/shadow-outbox.d.ts.map +1 -1
- package/dist/storage/shadow.d.ts.map +1 -1
- package/dist/storage.js +510 -137
- package/dist/task-manifest.js +118 -47
- package/dist/task-subtree-transfer.js +326 -44
- package/dist/types/index.d.ts +2 -2
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +6 -4
- package/dashboard/dist/assets/index-DJm6m6Yy.css +0 -1
- package/dashboard/dist/assets/index-DVotjwab.js +0 -346
package/dist/storage.js
CHANGED
|
@@ -5531,13 +5531,15 @@ function upsertSecretSafetyConfig(input) {
|
|
|
5531
5531
|
saveConfig({ ...config, secret_safety: next });
|
|
5532
5532
|
return next;
|
|
5533
5533
|
}
|
|
5534
|
-
var DEFAULT_SECRET_PATTERNS, DEFAULT_SECRET_KEY_PATTERN, NON_SECRET_USAGE_KEYS, REDACTION_PLACEHOLDER;
|
|
5534
|
+
var XAI_PREFIX, DEFAULT_SECRET_PATTERNS, DEFAULT_SECRET_KEY_PATTERN, NON_SECRET_USAGE_KEYS, REDACTION_PLACEHOLDER;
|
|
5535
5535
|
var init_redaction = __esm(() => {
|
|
5536
5536
|
init_config2();
|
|
5537
|
+
XAI_PREFIX = ["x", "ai", "-"].join("");
|
|
5537
5538
|
DEFAULT_SECRET_PATTERNS = [
|
|
5538
5539
|
{ name: "aws-access-key", regex: /\b(AKIA|ASIA)[0-9A-Z]{16}\b/g, replacement: "[REDACTED_AWS_KEY]" },
|
|
5539
5540
|
{ name: "private-key", regex: /-----BEGIN (?:RSA |EC |OPENSSH |)PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH |)PRIVATE KEY-----/g, replacement: "[REDACTED_PRIVATE_KEY]" },
|
|
5540
5541
|
{ name: "openai-token", regex: /\bsk-[A-Za-z0-9_-]{12,}\b/g, replacement: "[REDACTED_TOKEN]" },
|
|
5542
|
+
{ name: `${XAI_PREFIX}token`, regex: /\bxai[-][A-Za-z0-9]{20,80}\b/g, replacement: "[REDACTED_TOKEN]" },
|
|
5541
5543
|
{ name: "npm-token", regex: /\bnpm_[A-Za-z0-9]{20,}\b/g, replacement: "[REDACTED_NPM_TOKEN]" },
|
|
5542
5544
|
{ name: "github-fine-grained-token", regex: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, replacement: "[REDACTED_GITHUB_TOKEN]" },
|
|
5543
5545
|
{ name: "github-token", regex: /\bgh[opsu]_[A-Za-z0-9]{20,}\b/g, replacement: "[REDACTED_GITHUB_TOKEN]" },
|
|
@@ -6194,8 +6196,9 @@ var init_event_hooks = __esm(() => {
|
|
|
6194
6196
|
VALID_TARGETS = new Set(["stdout", "file", "socket", "script"]);
|
|
6195
6197
|
});
|
|
6196
6198
|
|
|
6197
|
-
// node_modules/.bun/@hasna+events@0.1.
|
|
6199
|
+
// node_modules/.bun/@hasna+events@0.1.16/node_modules/@hasna/events/dist/index.js
|
|
6198
6200
|
import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
6201
|
+
import { Buffer as Buffer2 } from "buffer";
|
|
6199
6202
|
import { existsSync as existsSync5 } from "fs";
|
|
6200
6203
|
import { homedir as homedir2 } from "os";
|
|
6201
6204
|
import { join as join4 } from "path";
|
|
@@ -6304,11 +6307,13 @@ function getEventsDataDir(override) {
|
|
|
6304
6307
|
|
|
6305
6308
|
class JsonEventsStore {
|
|
6306
6309
|
dataDir;
|
|
6310
|
+
runtime;
|
|
6307
6311
|
channelsPath;
|
|
6308
6312
|
eventsPath;
|
|
6309
6313
|
deliveriesPath;
|
|
6310
6314
|
constructor(dataDir = getEventsDataDir()) {
|
|
6311
6315
|
this.dataDir = dataDir;
|
|
6316
|
+
this.runtime = localJsonRuntime(dataDir);
|
|
6312
6317
|
this.channelsPath = join4(dataDir, "channels.json");
|
|
6313
6318
|
this.eventsPath = join4(dataDir, "events.json");
|
|
6314
6319
|
this.deliveriesPath = join4(dataDir, "deliveries.json");
|
|
@@ -6356,13 +6361,58 @@ class JsonEventsStore {
|
|
|
6356
6361
|
await this.writeJson(this.eventsPath, events);
|
|
6357
6362
|
return event;
|
|
6358
6363
|
}
|
|
6359
|
-
async
|
|
6364
|
+
async appendEventOnce(event, options = {}) {
|
|
6360
6365
|
await this.init();
|
|
6361
|
-
|
|
6366
|
+
const events = await this.readJson(this.eventsPath, []);
|
|
6367
|
+
const dedupe = options.dedupe !== false;
|
|
6368
|
+
if (dedupe) {
|
|
6369
|
+
const existing = findEventByIdentity(events, { id: event.id, dedupeKey: event.dedupeKey });
|
|
6370
|
+
if (existing) {
|
|
6371
|
+
return {
|
|
6372
|
+
event: existing,
|
|
6373
|
+
stored: false,
|
|
6374
|
+
deduped: true,
|
|
6375
|
+
identity: { id: existing.id, dedupeKey: existing.dedupeKey }
|
|
6376
|
+
};
|
|
6377
|
+
}
|
|
6378
|
+
}
|
|
6379
|
+
events.push(event);
|
|
6380
|
+
await this.writeJson(this.eventsPath, events);
|
|
6381
|
+
return {
|
|
6382
|
+
event,
|
|
6383
|
+
stored: true,
|
|
6384
|
+
deduped: false,
|
|
6385
|
+
identity: { id: event.id, dedupeKey: event.dedupeKey }
|
|
6386
|
+
};
|
|
6387
|
+
}
|
|
6388
|
+
async listEvents(options = {}) {
|
|
6389
|
+
await this.init();
|
|
6390
|
+
const events = await this.readJson(this.eventsPath, []);
|
|
6391
|
+
return queryEvents(events, options);
|
|
6392
|
+
}
|
|
6393
|
+
async listEventsPage(options = {}) {
|
|
6394
|
+
await this.init();
|
|
6395
|
+
const events = await this.readJson(this.eventsPath, []);
|
|
6396
|
+
const queried = queryEvents(events, {
|
|
6397
|
+
eventId: options.eventId,
|
|
6398
|
+
source: options.source,
|
|
6399
|
+
type: options.type
|
|
6400
|
+
});
|
|
6401
|
+
const offset = decodeLocalJsonEventCursor(options.cursor, options);
|
|
6402
|
+
const limit = normalizeEventPageLimit(options.limit);
|
|
6403
|
+
const pageEvents = queried.slice(offset, offset + limit);
|
|
6404
|
+
const nextOffset = offset + pageEvents.length;
|
|
6405
|
+
const hasMore = nextOffset < queried.length;
|
|
6406
|
+
return {
|
|
6407
|
+
events: pageEvents,
|
|
6408
|
+
cursor: options.cursor,
|
|
6409
|
+
nextCursor: hasMore ? encodeLocalJsonEventCursor(nextOffset, options) : undefined,
|
|
6410
|
+
hasMore
|
|
6411
|
+
};
|
|
6362
6412
|
}
|
|
6363
6413
|
async findEventByIdentity(identity) {
|
|
6364
6414
|
const events = await this.listEvents();
|
|
6365
|
-
return events
|
|
6415
|
+
return findEventByIdentity(events, identity);
|
|
6366
6416
|
}
|
|
6367
6417
|
async appendDelivery(result) {
|
|
6368
6418
|
await this.init();
|
|
@@ -6413,6 +6463,83 @@ class JsonEventsStore {
|
|
|
6413
6463
|
});
|
|
6414
6464
|
}
|
|
6415
6465
|
}
|
|
6466
|
+
function localJsonRuntime(dataDir = getEventsDataDir()) {
|
|
6467
|
+
return {
|
|
6468
|
+
mode: "local-files",
|
|
6469
|
+
name: "json-events-store",
|
|
6470
|
+
remote: false,
|
|
6471
|
+
localFiles: true,
|
|
6472
|
+
localSqlite: false,
|
|
6473
|
+
postgres: false,
|
|
6474
|
+
s3: false,
|
|
6475
|
+
aws: false,
|
|
6476
|
+
durable: true,
|
|
6477
|
+
idempotency: "best-effort-local",
|
|
6478
|
+
replayCursors: true,
|
|
6479
|
+
description: `Local JSON files in ${dataDir}; no SQLite, Postgres, S3, or AWS runtime is configured by this store.`
|
|
6480
|
+
};
|
|
6481
|
+
}
|
|
6482
|
+
function encodeLocalJsonEventCursor(offset, options = {}) {
|
|
6483
|
+
if (!Number.isInteger(offset) || offset < 0)
|
|
6484
|
+
throw new Error(`Invalid event cursor offset: ${offset}`);
|
|
6485
|
+
const payload = {
|
|
6486
|
+
offset,
|
|
6487
|
+
eventId: options.eventId,
|
|
6488
|
+
source: options.source,
|
|
6489
|
+
type: options.type
|
|
6490
|
+
};
|
|
6491
|
+
return `${LOCAL_JSON_EVENT_CURSOR_PREFIX}${Buffer2.from(JSON.stringify(payload), "utf-8").toString("base64url")}`;
|
|
6492
|
+
}
|
|
6493
|
+
function decodeLocalJsonEventCursor(cursor, options = {}) {
|
|
6494
|
+
if (!cursor)
|
|
6495
|
+
return 0;
|
|
6496
|
+
if (!cursor.startsWith(LOCAL_JSON_EVENT_CURSOR_PREFIX))
|
|
6497
|
+
throw new Error(`Invalid local JSON event cursor: ${cursor}`);
|
|
6498
|
+
const rawPayload = cursor.slice(LOCAL_JSON_EVENT_CURSOR_PREFIX.length);
|
|
6499
|
+
let payload;
|
|
6500
|
+
try {
|
|
6501
|
+
payload = JSON.parse(Buffer2.from(rawPayload, "base64url").toString("utf-8"));
|
|
6502
|
+
} catch {
|
|
6503
|
+
throw new Error(`Invalid local JSON event cursor: ${cursor}`);
|
|
6504
|
+
}
|
|
6505
|
+
const offset = payload.offset;
|
|
6506
|
+
if (!Number.isInteger(offset) || offset < 0)
|
|
6507
|
+
throw new Error(`Invalid local JSON event cursor: ${cursor}`);
|
|
6508
|
+
assertCursorFilter("eventId", payload.eventId, options.eventId);
|
|
6509
|
+
assertCursorFilter("source", payload.source, options.source);
|
|
6510
|
+
assertCursorFilter("type", payload.type, options.type);
|
|
6511
|
+
return offset;
|
|
6512
|
+
}
|
|
6513
|
+
function normalizeEventPageLimit(limit) {
|
|
6514
|
+
if (limit === undefined)
|
|
6515
|
+
return DEFAULT_EVENT_PAGE_LIMIT;
|
|
6516
|
+
if (!Number.isInteger(limit) || limit < 1)
|
|
6517
|
+
throw new Error(`Event page limit must be a positive integer, got ${limit}`);
|
|
6518
|
+
return Math.min(limit, MAX_EVENT_PAGE_LIMIT);
|
|
6519
|
+
}
|
|
6520
|
+
function queryEvents(events, options) {
|
|
6521
|
+
let rows = events;
|
|
6522
|
+
if (options.eventId)
|
|
6523
|
+
rows = rows.filter((event) => event.id === options.eventId);
|
|
6524
|
+
if (options.source)
|
|
6525
|
+
rows = rows.filter((event) => event.source === options.source);
|
|
6526
|
+
if (options.type)
|
|
6527
|
+
rows = rows.filter((event) => event.type === options.type);
|
|
6528
|
+
if (options.cursor) {
|
|
6529
|
+
const offset = decodeLocalJsonEventCursor(options.cursor, options);
|
|
6530
|
+
rows = rows.slice(offset);
|
|
6531
|
+
}
|
|
6532
|
+
if (options.limit !== undefined)
|
|
6533
|
+
rows = rows.slice(0, normalizeEventPageLimit(options.limit));
|
|
6534
|
+
return rows;
|
|
6535
|
+
}
|
|
6536
|
+
function assertCursorFilter(name, cursorValue, optionValue) {
|
|
6537
|
+
if (cursorValue !== optionValue)
|
|
6538
|
+
throw new Error(`Local JSON event cursor ${name} filter mismatch`);
|
|
6539
|
+
}
|
|
6540
|
+
function findEventByIdentity(events, identity) {
|
|
6541
|
+
return events.find((event) => identity.id !== undefined && event.id === identity.id || identity.dedupeKey !== undefined && event.dedupeKey === identity.dedupeKey);
|
|
6542
|
+
}
|
|
6416
6543
|
function buildSignatureBase(timestamp2, body) {
|
|
6417
6544
|
return `${timestamp2}.${body}`;
|
|
6418
6545
|
}
|
|
@@ -6426,21 +6553,27 @@ function now2() {
|
|
|
6426
6553
|
function truncate(value, max = 4096) {
|
|
6427
6554
|
return value.length > max ? `${value.slice(0, max)}...` : value;
|
|
6428
6555
|
}
|
|
6429
|
-
function buildWebhookRequest(event, channel) {
|
|
6556
|
+
function buildWebhookRequest(event, channel, options = {}) {
|
|
6430
6557
|
if (!channel.webhook)
|
|
6431
6558
|
throw new Error(`Channel ${channel.id} has no webhook config`);
|
|
6559
|
+
for (const name of Object.keys(channel.webhook.headers ?? {})) {
|
|
6560
|
+
if (/^x-hasna-/i.test(name)) {
|
|
6561
|
+
throw new Error(`Webhook header ${name} is reserved for signed delivery metadata`);
|
|
6562
|
+
}
|
|
6563
|
+
}
|
|
6432
6564
|
const body = JSON.stringify(event);
|
|
6433
|
-
const timestamp2 =
|
|
6565
|
+
const timestamp2 = options.timestamp ?? new Date().toISOString();
|
|
6434
6566
|
const headers = {
|
|
6435
6567
|
"Content-Type": "application/json",
|
|
6436
6568
|
"User-Agent": "@hasna/events",
|
|
6437
6569
|
"X-Hasna-Event-Id": event.id,
|
|
6438
6570
|
"X-Hasna-Event-Type": event.type,
|
|
6439
|
-
|
|
6440
|
-
|
|
6571
|
+
...channel.webhook.headers,
|
|
6572
|
+
"X-Hasna-Timestamp": timestamp2
|
|
6441
6573
|
};
|
|
6442
|
-
|
|
6443
|
-
|
|
6574
|
+
const secret = options.secret ?? channel.webhook.secret;
|
|
6575
|
+
if (secret) {
|
|
6576
|
+
headers["X-Hasna-Signature"] = signPayload(secret, timestamp2, body);
|
|
6444
6577
|
}
|
|
6445
6578
|
return { body, headers };
|
|
6446
6579
|
}
|
|
@@ -6448,7 +6581,21 @@ async function dispatchWebhook(event, channel, options = {}) {
|
|
|
6448
6581
|
if (!channel.webhook)
|
|
6449
6582
|
throw new Error(`Channel ${channel.id} has no webhook config`);
|
|
6450
6583
|
const startedAt = now2();
|
|
6451
|
-
|
|
6584
|
+
let secret = channel.webhook.secret;
|
|
6585
|
+
if (channel.webhook.secretRef) {
|
|
6586
|
+
if (!options.secretResolver) {
|
|
6587
|
+
return failedAttempt(startedAt, "Webhook secret reference has no runtime resolver");
|
|
6588
|
+
}
|
|
6589
|
+
try {
|
|
6590
|
+
secret = await options.secretResolver(channel.webhook.secretRef);
|
|
6591
|
+
} catch {
|
|
6592
|
+
return failedAttempt(startedAt, "Webhook secret reference could not be resolved");
|
|
6593
|
+
}
|
|
6594
|
+
if (!secret)
|
|
6595
|
+
return failedAttempt(startedAt, "Webhook secret reference could not be resolved");
|
|
6596
|
+
}
|
|
6597
|
+
const timestamp2 = (options.now?.() ?? new Date).toISOString();
|
|
6598
|
+
const { body, headers } = buildWebhookRequest(event, channel, { secret, timestamp: timestamp2 });
|
|
6452
6599
|
const controller = new AbortController;
|
|
6453
6600
|
const timeout = setTimeout(() => controller.abort(), channel.webhook.timeoutMs ?? 15000);
|
|
6454
6601
|
try {
|
|
@@ -6480,6 +6627,15 @@ async function dispatchWebhook(event, channel, options = {}) {
|
|
|
6480
6627
|
clearTimeout(timeout);
|
|
6481
6628
|
}
|
|
6482
6629
|
}
|
|
6630
|
+
function failedAttempt(startedAt, error) {
|
|
6631
|
+
return {
|
|
6632
|
+
attempt: 1,
|
|
6633
|
+
status: "failed",
|
|
6634
|
+
startedAt,
|
|
6635
|
+
completedAt: now2(),
|
|
6636
|
+
error
|
|
6637
|
+
};
|
|
6638
|
+
}
|
|
6483
6639
|
async function dispatchCommand(event, channel) {
|
|
6484
6640
|
if (!channel.command)
|
|
6485
6641
|
throw new Error(`Channel ${channel.id} has no command config`);
|
|
@@ -6568,6 +6724,76 @@ function createDeliveryResult(event, channel, attempts) {
|
|
|
6568
6724
|
completedAt: attempts.at(-1)?.completedAt ?? now2()
|
|
6569
6725
|
};
|
|
6570
6726
|
}
|
|
6727
|
+
|
|
6728
|
+
class EventTypeCatalog {
|
|
6729
|
+
definitions = new Map;
|
|
6730
|
+
register(definition) {
|
|
6731
|
+
this.definitions.set(definition.type, definition);
|
|
6732
|
+
return this;
|
|
6733
|
+
}
|
|
6734
|
+
unregister(type) {
|
|
6735
|
+
return this.definitions.delete(type);
|
|
6736
|
+
}
|
|
6737
|
+
has(type) {
|
|
6738
|
+
return this.definitions.has(type);
|
|
6739
|
+
}
|
|
6740
|
+
get(type) {
|
|
6741
|
+
return this.definitions.get(type);
|
|
6742
|
+
}
|
|
6743
|
+
list() {
|
|
6744
|
+
return [...this.definitions.values()];
|
|
6745
|
+
}
|
|
6746
|
+
validateEvent(event) {
|
|
6747
|
+
const definition = this.definitions.get(event.type);
|
|
6748
|
+
if (!definition)
|
|
6749
|
+
return { ok: true };
|
|
6750
|
+
return definition.validate(event.data, event);
|
|
6751
|
+
}
|
|
6752
|
+
assertEventValid(event) {
|
|
6753
|
+
const result = this.validateEvent(event);
|
|
6754
|
+
if (!result.ok) {
|
|
6755
|
+
throw new EventValidationError(event.type, result.issues);
|
|
6756
|
+
}
|
|
6757
|
+
}
|
|
6758
|
+
}
|
|
6759
|
+
function redactPaths(event, paths, replacement = "[REDACTED]") {
|
|
6760
|
+
if (paths.length === 0)
|
|
6761
|
+
return event;
|
|
6762
|
+
const copy = structuredClone(event);
|
|
6763
|
+
for (const path of paths) {
|
|
6764
|
+
setPath(copy, path, replacement);
|
|
6765
|
+
}
|
|
6766
|
+
return copy;
|
|
6767
|
+
}
|
|
6768
|
+
function redactSensitiveKeys(event, replacement = "[REDACTED]") {
|
|
6769
|
+
return redactValue2(event, replacement);
|
|
6770
|
+
}
|
|
6771
|
+
function shouldRedactKey(key) {
|
|
6772
|
+
return /secret|token|password|api[_-]?key|authorization/i.test(key);
|
|
6773
|
+
}
|
|
6774
|
+
function redactValue2(value, replacement) {
|
|
6775
|
+
if (Array.isArray(value))
|
|
6776
|
+
return value.map((item) => redactValue2(item, replacement));
|
|
6777
|
+
if (!value || typeof value !== "object")
|
|
6778
|
+
return value;
|
|
6779
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
6780
|
+
key,
|
|
6781
|
+
shouldRedactKey(key) ? replacement : redactValue2(item, replacement)
|
|
6782
|
+
]));
|
|
6783
|
+
}
|
|
6784
|
+
function setPath(input, path, replacement) {
|
|
6785
|
+
const parts = path.split(".");
|
|
6786
|
+
let cursor = input;
|
|
6787
|
+
for (const part of parts.slice(0, -1)) {
|
|
6788
|
+
const next = cursor[part];
|
|
6789
|
+
if (!next || typeof next !== "object")
|
|
6790
|
+
return;
|
|
6791
|
+
cursor = next;
|
|
6792
|
+
}
|
|
6793
|
+
const last = parts.at(-1);
|
|
6794
|
+
if (last && last in cursor)
|
|
6795
|
+
cursor[last] = replacement;
|
|
6796
|
+
}
|
|
6571
6797
|
function createEvent(input) {
|
|
6572
6798
|
return {
|
|
6573
6799
|
id: input.id ?? randomUUID22(),
|
|
@@ -6588,10 +6814,18 @@ class EventsClient {
|
|
|
6588
6814
|
store;
|
|
6589
6815
|
redactors;
|
|
6590
6816
|
transportOptions;
|
|
6817
|
+
catalog;
|
|
6818
|
+
validateCatalogTypes;
|
|
6591
6819
|
constructor(options = {}) {
|
|
6592
6820
|
this.store = options.store ?? new JsonEventsStore(options.dataDir);
|
|
6593
6821
|
this.redactors = options.redactors ?? [];
|
|
6594
|
-
this.transportOptions = {
|
|
6822
|
+
this.transportOptions = {
|
|
6823
|
+
fetchImpl: options.fetchImpl,
|
|
6824
|
+
secretResolver: options.secretResolver,
|
|
6825
|
+
now: options.now
|
|
6826
|
+
};
|
|
6827
|
+
this.catalog = options.catalog ?? defaultEventTypeCatalog;
|
|
6828
|
+
this.validateCatalogTypes = options.validateCatalogTypes ?? false;
|
|
6595
6829
|
}
|
|
6596
6830
|
async addChannel(input) {
|
|
6597
6831
|
const timestamp2 = new Date().toISOString();
|
|
@@ -6609,18 +6843,40 @@ class EventsClient {
|
|
|
6609
6843
|
}
|
|
6610
6844
|
async emit(input, options = {}) {
|
|
6611
6845
|
const event = options.redactSensitiveData === false ? createEvent(input) : redactSensitiveKeys(createEvent(input));
|
|
6612
|
-
if (options.
|
|
6613
|
-
|
|
6614
|
-
|
|
6615
|
-
|
|
6616
|
-
|
|
6617
|
-
|
|
6618
|
-
|
|
6619
|
-
const deliveries = options.deliver === false ? [] : await this.deliver(event);
|
|
6620
|
-
return { event, deliveries, deduped: false };
|
|
6621
|
-
}
|
|
6622
|
-
async listEvents() {
|
|
6623
|
-
|
|
6846
|
+
if (options.validate ?? this.validateCatalogTypes) {
|
|
6847
|
+
this.catalog.assertEventValid(event);
|
|
6848
|
+
}
|
|
6849
|
+
const append = await this.appendEvent(event, { dedupe: options.dedupe !== false });
|
|
6850
|
+
if (append.deduped) {
|
|
6851
|
+
return { event: append.event, deliveries: [], deduped: true };
|
|
6852
|
+
}
|
|
6853
|
+
const deliveries = options.deliver === false ? [] : await this.deliver(append.event);
|
|
6854
|
+
return { event: append.event, deliveries, deduped: false };
|
|
6855
|
+
}
|
|
6856
|
+
async listEvents(options = {}) {
|
|
6857
|
+
if (Object.keys(options).length === 0)
|
|
6858
|
+
return this.store.listEvents();
|
|
6859
|
+
return queryClientEvents(await this.store.listEvents(), options);
|
|
6860
|
+
}
|
|
6861
|
+
async listEventsPage(options = {}) {
|
|
6862
|
+
if (this.store.listEventsPage)
|
|
6863
|
+
return this.store.listEventsPage(options);
|
|
6864
|
+
const events = queryClientEvents(await this.store.listEvents(), {
|
|
6865
|
+
eventId: options.eventId,
|
|
6866
|
+
source: options.source,
|
|
6867
|
+
type: options.type
|
|
6868
|
+
});
|
|
6869
|
+
const offset = decodeLocalJsonEventCursor(options.cursor, options);
|
|
6870
|
+
const limit = normalizeEventPageLimit(options.limit);
|
|
6871
|
+
const pageEvents = events.slice(offset, offset + limit);
|
|
6872
|
+
const nextOffset = offset + pageEvents.length;
|
|
6873
|
+
const hasMore = nextOffset < events.length;
|
|
6874
|
+
return {
|
|
6875
|
+
events: pageEvents,
|
|
6876
|
+
cursor: options.cursor,
|
|
6877
|
+
nextCursor: hasMore ? encodeLocalJsonEventCursor(nextOffset, options) : undefined,
|
|
6878
|
+
hasMore
|
|
6879
|
+
};
|
|
6624
6880
|
}
|
|
6625
6881
|
async listDeliveries() {
|
|
6626
6882
|
return this.store.listDeliveries();
|
|
@@ -6688,22 +6944,37 @@ class EventsClient {
|
|
|
6688
6944
|
return result;
|
|
6689
6945
|
}
|
|
6690
6946
|
async replay(options = {}) {
|
|
6691
|
-
const
|
|
6692
|
-
if (options.eventId && event.id !== options.eventId)
|
|
6693
|
-
return false;
|
|
6694
|
-
if (options.source && event.source !== options.source)
|
|
6695
|
-
return false;
|
|
6696
|
-
if (options.type && event.type !== options.type)
|
|
6697
|
-
return false;
|
|
6698
|
-
return true;
|
|
6699
|
-
});
|
|
6947
|
+
const page = options.cursor || options.limit !== undefined ? await this.listEventsPage(options) : { events: await this.listEvents(options), hasMore: false };
|
|
6700
6948
|
if (options.dryRun)
|
|
6701
|
-
return { events, deliveries: [] };
|
|
6949
|
+
return { events: page.events, deliveries: [], cursor: page.cursor, nextCursor: page.nextCursor, hasMore: page.hasMore };
|
|
6702
6950
|
const deliveries = [];
|
|
6703
|
-
for (const event of events) {
|
|
6951
|
+
for (const event of page.events) {
|
|
6704
6952
|
deliveries.push(...await this.deliver(event));
|
|
6705
6953
|
}
|
|
6706
|
-
return { events, deliveries };
|
|
6954
|
+
return { events: page.events, deliveries, cursor: page.cursor, nextCursor: page.nextCursor, hasMore: page.hasMore };
|
|
6955
|
+
}
|
|
6956
|
+
async appendEvent(event, options) {
|
|
6957
|
+
if (this.store.appendEventOnce) {
|
|
6958
|
+
return this.store.appendEventOnce(event, { dedupe: options.dedupe });
|
|
6959
|
+
}
|
|
6960
|
+
if (options.dedupe) {
|
|
6961
|
+
const existing = await this.store.findEventByIdentity({ id: event.id, dedupeKey: event.dedupeKey });
|
|
6962
|
+
if (existing) {
|
|
6963
|
+
return {
|
|
6964
|
+
event: existing,
|
|
6965
|
+
stored: false,
|
|
6966
|
+
deduped: true,
|
|
6967
|
+
identity: { id: existing.id, dedupeKey: existing.dedupeKey }
|
|
6968
|
+
};
|
|
6969
|
+
}
|
|
6970
|
+
}
|
|
6971
|
+
const stored = await this.store.appendEvent(event);
|
|
6972
|
+
return {
|
|
6973
|
+
event: stored,
|
|
6974
|
+
stored: true,
|
|
6975
|
+
deduped: false,
|
|
6976
|
+
identity: { id: stored.id, dedupeKey: stored.dedupeKey }
|
|
6977
|
+
};
|
|
6707
6978
|
}
|
|
6708
6979
|
async applyRedaction(event, channel) {
|
|
6709
6980
|
let next = redactPaths(event, channel.redact?.paths ?? [], channel.redact?.replacement ?? "[REDACTED]");
|
|
@@ -6730,43 +7001,19 @@ class EventsClient {
|
|
|
6730
7001
|
return createDeliveryResult(event, channel, attempts);
|
|
6731
7002
|
}
|
|
6732
7003
|
}
|
|
6733
|
-
function
|
|
6734
|
-
|
|
6735
|
-
|
|
6736
|
-
|
|
6737
|
-
|
|
6738
|
-
|
|
6739
|
-
|
|
6740
|
-
|
|
6741
|
-
|
|
6742
|
-
|
|
6743
|
-
|
|
6744
|
-
|
|
6745
|
-
|
|
6746
|
-
return /secret|token|password|api[_-]?key|authorization/i.test(key);
|
|
6747
|
-
}
|
|
6748
|
-
function redactValue2(value, replacement) {
|
|
6749
|
-
if (Array.isArray(value))
|
|
6750
|
-
return value.map((item) => redactValue2(item, replacement));
|
|
6751
|
-
if (!value || typeof value !== "object")
|
|
6752
|
-
return value;
|
|
6753
|
-
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
6754
|
-
key,
|
|
6755
|
-
shouldRedactKey(key) ? replacement : redactValue2(item, replacement)
|
|
6756
|
-
]));
|
|
6757
|
-
}
|
|
6758
|
-
function setPath(input, path, replacement) {
|
|
6759
|
-
const parts = path.split(".");
|
|
6760
|
-
let cursor = input;
|
|
6761
|
-
for (const part of parts.slice(0, -1)) {
|
|
6762
|
-
const next = cursor[part];
|
|
6763
|
-
if (!next || typeof next !== "object")
|
|
6764
|
-
return;
|
|
6765
|
-
cursor = next;
|
|
6766
|
-
}
|
|
6767
|
-
const last = parts.at(-1);
|
|
6768
|
-
if (last && last in cursor)
|
|
6769
|
-
cursor[last] = replacement;
|
|
7004
|
+
function queryClientEvents(events, options) {
|
|
7005
|
+
let rows = events;
|
|
7006
|
+
if (options.eventId)
|
|
7007
|
+
rows = rows.filter((event) => event.id === options.eventId);
|
|
7008
|
+
if (options.source)
|
|
7009
|
+
rows = rows.filter((event) => event.source === options.source);
|
|
7010
|
+
if (options.type)
|
|
7011
|
+
rows = rows.filter((event) => event.type === options.type);
|
|
7012
|
+
if (options.cursor)
|
|
7013
|
+
rows = rows.slice(decodeLocalJsonEventCursor(options.cursor, options));
|
|
7014
|
+
if (options.limit !== undefined)
|
|
7015
|
+
rows = rows.slice(0, normalizeEventPageLimit(options.limit));
|
|
7016
|
+
return rows;
|
|
6770
7017
|
}
|
|
6771
7018
|
function normalizeTime(value) {
|
|
6772
7019
|
if (!value)
|
|
@@ -6780,9 +7027,22 @@ function normalizeRetryPolicy(policy) {
|
|
|
6780
7027
|
multiplier: Math.max(1, policy?.multiplier ?? 2)
|
|
6781
7028
|
};
|
|
6782
7029
|
}
|
|
6783
|
-
var HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR", HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME", DEFAULT_SIGNATURE_TOLERANCE_MS;
|
|
7030
|
+
var HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR", HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME", LOCAL_JSON_EVENT_CURSOR_PREFIX = "local-json-v1:", DEFAULT_EVENT_PAGE_LIMIT = 100, MAX_EVENT_PAGE_LIMIT = 1000, DEFAULT_SIGNATURE_TOLERANCE_MS, EventValidationError, defaultEventTypeCatalog, APP_EVENT_V1_MAX_DATA_BYTES;
|
|
6784
7031
|
var init_dist = __esm(() => {
|
|
6785
7032
|
DEFAULT_SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
|
|
7033
|
+
EventValidationError = class EventValidationError extends Error {
|
|
7034
|
+
eventType;
|
|
7035
|
+
issues;
|
|
7036
|
+
constructor(eventType, issues) {
|
|
7037
|
+
const detail = issues.map((issue) => `${issue.path || "<root>"}: ${issue.message}`).join("; ");
|
|
7038
|
+
super(`Event validation failed for type "${eventType}": ${detail}`);
|
|
7039
|
+
this.name = "EventValidationError";
|
|
7040
|
+
this.eventType = eventType;
|
|
7041
|
+
this.issues = issues;
|
|
7042
|
+
}
|
|
7043
|
+
};
|
|
7044
|
+
defaultEventTypeCatalog = new EventTypeCatalog;
|
|
7045
|
+
APP_EVENT_V1_MAX_DATA_BYTES = 32 * 1024;
|
|
6786
7046
|
});
|
|
6787
7047
|
|
|
6788
7048
|
// src/db/task-lists.ts
|
|
@@ -7886,8 +8146,12 @@ async function deliverWebhook(wh, event, body, attempt, db) {
|
|
|
7886
8146
|
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(body));
|
|
7887
8147
|
headers["X-Webhook-Signature"] = Array.from(new Uint8Array(sig)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
7888
8148
|
}
|
|
7889
|
-
const resp = await fetch(wh.url, { method: "POST", headers, body });
|
|
8149
|
+
const resp = await fetch(wh.url, { method: "POST", headers, body, redirect: "manual" });
|
|
7890
8150
|
const respText = await resp.text().catch(() => "");
|
|
8151
|
+
if (resp.status >= 300 && resp.status < 400) {
|
|
8152
|
+
logDelivery(db, wh.id, event, body, resp.status, "Blocked: webhook URL returned a 3xx redirect; redirects are never followed (SSRF prevention)", attempt);
|
|
8153
|
+
return;
|
|
8154
|
+
}
|
|
7891
8155
|
logDelivery(db, wh.id, event, body, resp.status, respText.slice(0, 1000), attempt);
|
|
7892
8156
|
if (resp.status >= 400 && attempt < MAX_RETRY_ATTEMPTS) {
|
|
7893
8157
|
const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1);
|
|
@@ -9671,6 +9935,12 @@ function listTasks(filter = {}, db) {
|
|
|
9671
9935
|
const d = db || getDatabase();
|
|
9672
9936
|
const { clearExpiredLocks: clearExpiredLocks2 } = (init_database(), __toCommonJS(exports_database));
|
|
9673
9937
|
clearExpiredLocks2(d);
|
|
9938
|
+
if (filter.limit !== undefined) {
|
|
9939
|
+
const limit = filter.limit;
|
|
9940
|
+
if (!Number.isSafeInteger(limit) || limit <= 0 || limit > MAX_TASK_LIST_LIMIT) {
|
|
9941
|
+
throw new TypeError(`listTasks limit must be a positive integer between 1 and ${MAX_TASK_LIST_LIMIT}; got ${String(limit)}`);
|
|
9942
|
+
}
|
|
9943
|
+
}
|
|
9674
9944
|
const conditions = [];
|
|
9675
9945
|
const params = [];
|
|
9676
9946
|
if (filter.project_id) {
|
|
@@ -10204,6 +10474,7 @@ function deleteTask(id, db) {
|
|
|
10204
10474
|
}
|
|
10205
10475
|
return result.changes > 0;
|
|
10206
10476
|
}
|
|
10477
|
+
var MAX_TASK_LIST_LIMIT = 1e5;
|
|
10207
10478
|
var init_task_crud = __esm(() => {
|
|
10208
10479
|
init_types();
|
|
10209
10480
|
init_database();
|
|
@@ -14915,6 +15186,30 @@ function changedSinceStampNewer(stamp, since) {
|
|
|
14915
15186
|
// src/storage/postgres-adapter.ts
|
|
14916
15187
|
init_stale_lock_handoff();
|
|
14917
15188
|
|
|
15189
|
+
// src/storage/postgres-sync.ts
|
|
15190
|
+
init_types();
|
|
15191
|
+
|
|
15192
|
+
// src/storage/postgres-errors.ts
|
|
15193
|
+
function isPostgresUniqueViolation(error) {
|
|
15194
|
+
if (typeof error !== "object" || error === null)
|
|
15195
|
+
return false;
|
|
15196
|
+
const candidate = error;
|
|
15197
|
+
const states = [candidate.code, candidate.errno, candidate.sqlState, candidate.sqlstate];
|
|
15198
|
+
if (typeof candidate.cause === "object" && candidate.cause !== null) {
|
|
15199
|
+
const cause = candidate.cause;
|
|
15200
|
+
states.push(cause.code, cause.errno, cause.sqlState, cause.sqlstate);
|
|
15201
|
+
}
|
|
15202
|
+
return states.some((state) => String(state) === "23505");
|
|
15203
|
+
}
|
|
15204
|
+
function postgresConstraintName(error) {
|
|
15205
|
+
if (typeof error !== "object" || error === null)
|
|
15206
|
+
return "";
|
|
15207
|
+
const candidate = error;
|
|
15208
|
+
const cause = typeof candidate.cause === "object" && candidate.cause !== null ? candidate.cause : undefined;
|
|
15209
|
+
const constraint = candidate.constraint ?? candidate.constraint_name ?? cause?.constraint ?? cause?.constraint_name;
|
|
15210
|
+
return typeof constraint === "string" ? constraint : "";
|
|
15211
|
+
}
|
|
15212
|
+
|
|
14918
15213
|
// src/storage/postgres-sync.ts
|
|
14919
15214
|
var DEFAULT_TODOS_POSTGRES_SYNC_TABLE = "todos_sync_records";
|
|
14920
15215
|
var DEFAULT_TODOS_POSTGRES_CURSOR_TABLE = "todos_sync_cursors";
|
|
@@ -15117,53 +15412,98 @@ class PostgresTodosSyncStore {
|
|
|
15117
15412
|
if (routingErrors.length > 0) {
|
|
15118
15413
|
throw new Error(`Invalid snapshot routing metadata: ${routingErrors.join("; ")}`);
|
|
15119
15414
|
}
|
|
15120
|
-
const
|
|
15121
|
-
|
|
15122
|
-
|
|
15123
|
-
|
|
15124
|
-
|
|
15125
|
-
|
|
15126
|
-
const
|
|
15127
|
-
|
|
15128
|
-
|
|
15129
|
-
|
|
15130
|
-
|
|
15131
|
-
|
|
15132
|
-
|
|
15133
|
-
|
|
15134
|
-
|
|
15135
|
-
|
|
15136
|
-
|
|
15137
|
-
|
|
15138
|
-
|
|
15139
|
-
|
|
15140
|
-
|
|
15141
|
-
|
|
15142
|
-
|
|
15143
|
-
|
|
15144
|
-
|
|
15145
|
-
|
|
15146
|
-
|
|
15147
|
-
|
|
15148
|
-
|
|
15149
|
-
|
|
15150
|
-
|
|
15151
|
-
|
|
15152
|
-
|
|
15153
|
-
|
|
15154
|
-
|
|
15155
|
-
|
|
15156
|
-
|
|
15157
|
-
|
|
15158
|
-
|
|
15159
|
-
|
|
15160
|
-
|
|
15161
|
-
|
|
15162
|
-
|
|
15163
|
-
|
|
15164
|
-
|
|
15415
|
+
const push = async (client) => {
|
|
15416
|
+
const existing = await client.query(`SELECT object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version
|
|
15417
|
+
FROM ${this.tableName}
|
|
15418
|
+
WHERE service = $1 AND object_type IN ($2, $3) AND deleted_at IS NULL`, [this.service, "projects", "task_lists"]);
|
|
15419
|
+
const existingProjects = [];
|
|
15420
|
+
const existingTaskLists = [];
|
|
15421
|
+
for (const row of existing.rows) {
|
|
15422
|
+
const payload = payloadRecord(row.payload);
|
|
15423
|
+
if (row.object_type === "projects")
|
|
15424
|
+
existingProjects.push(payload);
|
|
15425
|
+
if (row.object_type === "task_lists")
|
|
15426
|
+
existingTaskLists.push(payload);
|
|
15427
|
+
}
|
|
15428
|
+
const destinationErrors = validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists);
|
|
15429
|
+
if (destinationErrors.length > 0) {
|
|
15430
|
+
throw new ResourceConflictError("SNAPSHOT_DESTINATION_CONFLICT", `Snapshot routing conflicts with destination: ${destinationErrors.join("; ")}`);
|
|
15431
|
+
}
|
|
15432
|
+
const result = { records: 0, objectTypes: {} };
|
|
15433
|
+
const sourceMachineId = context.requestId ?? this.sourceMachineId ?? null;
|
|
15434
|
+
for (const entry of snapshotEntries(snapshot)) {
|
|
15435
|
+
if (entry.deletedAt === null)
|
|
15436
|
+
assertCanonicalScopedSlugEntry(entry);
|
|
15437
|
+
try {
|
|
15438
|
+
await client.query(`INSERT INTO ${this.tableName} (
|
|
15439
|
+
service, object_type, object_id, payload, updated_at,
|
|
15440
|
+
deleted_at, source_machine_id, version
|
|
15441
|
+
) VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, $6::timestamptz, $7, $8)
|
|
15442
|
+
ON CONFLICT (service, object_type, object_id) DO UPDATE SET
|
|
15443
|
+
payload = EXCLUDED.payload,
|
|
15444
|
+
updated_at = EXCLUDED.updated_at,
|
|
15445
|
+
deleted_at = EXCLUDED.deleted_at,
|
|
15446
|
+
source_machine_id = EXCLUDED.source_machine_id,
|
|
15447
|
+
version = EXCLUDED.version
|
|
15448
|
+
WHERE ${this.tableName}.updated_at < EXCLUDED.updated_at
|
|
15449
|
+
OR (${this.tableName}.updated_at = EXCLUDED.updated_at
|
|
15450
|
+
AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))`, [
|
|
15451
|
+
this.service,
|
|
15452
|
+
entry.type,
|
|
15453
|
+
entry.id,
|
|
15454
|
+
entry.payload,
|
|
15455
|
+
entry.updatedAt,
|
|
15456
|
+
entry.deletedAt,
|
|
15457
|
+
sourceMachineId,
|
|
15458
|
+
entry.version
|
|
15459
|
+
]);
|
|
15460
|
+
} catch (error) {
|
|
15461
|
+
await this.classifySyncInsertConflict(error, entry);
|
|
15462
|
+
}
|
|
15463
|
+
result.records += 1;
|
|
15464
|
+
result.objectTypes[entry.type] = (result.objectTypes[entry.type] ?? 0) + 1;
|
|
15465
|
+
}
|
|
15466
|
+
return result;
|
|
15467
|
+
};
|
|
15468
|
+
if (typeof this.client.transaction === "function") {
|
|
15469
|
+
return this.client.transaction((client) => push(client));
|
|
15165
15470
|
}
|
|
15166
|
-
return
|
|
15471
|
+
return push(this.client);
|
|
15472
|
+
}
|
|
15473
|
+
async classifySyncInsertConflict(error, entry) {
|
|
15474
|
+
if (!isPostgresUniqueViolation(error))
|
|
15475
|
+
throw error;
|
|
15476
|
+
const payload = entry.payload;
|
|
15477
|
+
const taskListSlug = String(payload["slug"] ?? "");
|
|
15478
|
+
const projectSlug = String(payload["task_list_id"] ?? "");
|
|
15479
|
+
const constraintName = postgresConstraintName(error);
|
|
15480
|
+
if (entry.type === "projects" && constraintName.includes("project_task_list_slug_uidx")) {
|
|
15481
|
+
throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${projectSlug}" already exists`);
|
|
15482
|
+
}
|
|
15483
|
+
if (entry.type === "task_lists" && constraintName.includes("task_list_scope_slug_uidx")) {
|
|
15484
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${taskListSlug}" already exists in this scope`);
|
|
15485
|
+
}
|
|
15486
|
+
if (entry.type === "task_lists") {
|
|
15487
|
+
const scope = typeof payload["project_id"] === "string" ? payload["project_id"] : "";
|
|
15488
|
+
const conflict = await this.client.query(`/* todos:classify-sync-task-list-conflict */ SELECT EXISTS (
|
|
15489
|
+
SELECT 1 FROM ${this.tableName}
|
|
15490
|
+
WHERE service = $1 AND object_type = 'task_lists' AND object_id <> $2
|
|
15491
|
+
AND deleted_at IS NULL AND COALESCE(payload->>'project_id','') = $3 AND payload->>'slug' = $4
|
|
15492
|
+
) AS conflict`, [this.service, entry.id, scope, taskListSlug]);
|
|
15493
|
+
if (conflict.rows[0]?.conflict) {
|
|
15494
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${taskListSlug}" already exists in this scope`);
|
|
15495
|
+
}
|
|
15496
|
+
} else if (entry.type === "projects") {
|
|
15497
|
+
const conflict = await this.client.query(`/* todos:classify-sync-project-conflict */ SELECT EXISTS (
|
|
15498
|
+
SELECT 1 FROM ${this.tableName}
|
|
15499
|
+
WHERE service = $1 AND object_type = 'projects' AND object_id <> $2
|
|
15500
|
+
AND deleted_at IS NULL AND payload->>'task_list_id' = $3
|
|
15501
|
+
) AS conflict`, [this.service, entry.id, projectSlug]);
|
|
15502
|
+
if (conflict.rows[0]?.conflict) {
|
|
15503
|
+
throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${projectSlug}" already exists`);
|
|
15504
|
+
}
|
|
15505
|
+
}
|
|
15506
|
+
throw error;
|
|
15167
15507
|
}
|
|
15168
15508
|
async pullSnapshot(options = {}) {
|
|
15169
15509
|
const params = [this.service];
|
|
@@ -15529,9 +15869,11 @@ class PostgresJsonRecordStore {
|
|
|
15529
15869
|
async ensureSchema() {
|
|
15530
15870
|
if (!this.schemaReady) {
|
|
15531
15871
|
this.schemaReady = (async () => {
|
|
15532
|
-
|
|
15533
|
-
|
|
15534
|
-
|
|
15872
|
+
await retryOnTransientPostgresError(async () => {
|
|
15873
|
+
for (const sql of postgresTodosSyncSchemaSql(this.tableName, this.cursorTableName)) {
|
|
15874
|
+
await this.options.client.query(sql);
|
|
15875
|
+
}
|
|
15876
|
+
});
|
|
15535
15877
|
})().catch((error) => {
|
|
15536
15878
|
this.schemaReady = null;
|
|
15537
15879
|
throw error;
|
|
@@ -15945,13 +16287,14 @@ class PostgresJsonRecordStore {
|
|
|
15945
16287
|
throw new Error(divergentAuditHistoryReplayError(value.id));
|
|
15946
16288
|
}
|
|
15947
16289
|
async withTaskParentIntegrityTransaction(fn) {
|
|
15948
|
-
|
|
16290
|
+
const transaction = this.options.client.transaction;
|
|
16291
|
+
if (typeof transaction !== "function") {
|
|
15949
16292
|
throw new Error("TASK_PARENT_ATOMICITY_UNAVAILABLE: PostgreSQL parent writes and task deletion require transaction(callback)");
|
|
15950
16293
|
}
|
|
15951
|
-
return
|
|
16294
|
+
return retryOnTransientPostgresError(() => transaction(async (client) => {
|
|
15952
16295
|
await client.query("/* todos:task-parent-integrity-lock */ SELECT pg_advisory_xact_lock(hashtextextended($1 || ':task-parent-integrity', 0))", [this.service]);
|
|
15953
16296
|
return fn(client);
|
|
15954
|
-
});
|
|
16297
|
+
}));
|
|
15955
16298
|
}
|
|
15956
16299
|
async upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context = {}, parentGuard, queryClient) {
|
|
15957
16300
|
const planIds = [...new Set(guardedPlanIds.filter(Boolean))].sort();
|
|
@@ -17837,7 +18180,13 @@ function compareClock(left, right) {
|
|
|
17837
18180
|
function numberValue2(value) {
|
|
17838
18181
|
return typeof value === "number" && Number.isSafeInteger(value) ? value : null;
|
|
17839
18182
|
}
|
|
17840
|
-
|
|
18183
|
+
var TRANSIENT_POSTGRES_SQLSTATES = new Set(["55P03", "40P01", "40001"]);
|
|
18184
|
+
var TRANSIENT_POSTGRES_MESSAGE_MARKERS = [
|
|
18185
|
+
"canceling statement due to lock timeout",
|
|
18186
|
+
"deadlock detected",
|
|
18187
|
+
"could not serialize access"
|
|
18188
|
+
];
|
|
18189
|
+
function isTransientPostgresError(error) {
|
|
17841
18190
|
if (typeof error !== "object" || error === null)
|
|
17842
18191
|
return false;
|
|
17843
18192
|
const candidate = error;
|
|
@@ -17846,18 +18195,29 @@ function isPostgresUniqueViolation(error) {
|
|
|
17846
18195
|
const cause = candidate.cause;
|
|
17847
18196
|
states.push(cause.code, cause.errno, cause.sqlState, cause.sqlstate);
|
|
17848
18197
|
}
|
|
17849
|
-
|
|
18198
|
+
if (states.some((state) => typeof state === "string" && TRANSIENT_POSTGRES_SQLSTATES.has(state))) {
|
|
18199
|
+
return true;
|
|
18200
|
+
}
|
|
18201
|
+
const message = typeof candidate.message === "string" ? candidate.message : candidate.cause?.message;
|
|
18202
|
+
return typeof message === "string" && TRANSIENT_POSTGRES_MESSAGE_MARKERS.some((marker) => message.includes(marker));
|
|
17850
18203
|
}
|
|
17851
|
-
function
|
|
17852
|
-
|
|
17853
|
-
|
|
17854
|
-
|
|
17855
|
-
|
|
17856
|
-
|
|
17857
|
-
|
|
18204
|
+
async function retryOnTransientPostgresError(fn, attempts = 2, delayMs = 150) {
|
|
18205
|
+
let lastError;
|
|
18206
|
+
for (let attempt = 1;attempt <= attempts; attempt += 1) {
|
|
18207
|
+
try {
|
|
18208
|
+
return await fn();
|
|
18209
|
+
} catch (error) {
|
|
18210
|
+
lastError = error;
|
|
18211
|
+
if (!isTransientPostgresError(error) || attempt === attempts)
|
|
18212
|
+
throw error;
|
|
18213
|
+
await new Promise((resolve8) => setTimeout(resolve8, delayMs * attempt));
|
|
18214
|
+
}
|
|
18215
|
+
}
|
|
18216
|
+
throw lastError;
|
|
17858
18217
|
}
|
|
17859
18218
|
|
|
17860
18219
|
// src/storage/shadow.ts
|
|
18220
|
+
init_types();
|
|
17861
18221
|
var SNAPSHOT_TO_OBJECT_TYPE = {
|
|
17862
18222
|
tasks: "tasks",
|
|
17863
18223
|
projects: "projects",
|
|
@@ -17999,6 +18359,12 @@ class TodosShadowMirror {
|
|
|
17999
18359
|
const message = error instanceof Error ? error.message : String(error);
|
|
18000
18360
|
const objectType = op.kind === "upsert" ? SNAPSHOT_TO_OBJECT_TYPE[op.key] : op.objectType;
|
|
18001
18361
|
const id = op.kind === "upsert" ? String(op.record["id"]) : op.id;
|
|
18362
|
+
if (error instanceof ResourceConflictError) {
|
|
18363
|
+
this.metrics.failed += 1;
|
|
18364
|
+
this.metrics.lastError = message;
|
|
18365
|
+
this.onEvent?.({ type: "dropped", objectType, id, error: message });
|
|
18366
|
+
return;
|
|
18367
|
+
}
|
|
18002
18368
|
this.metrics.retries += 1;
|
|
18003
18369
|
this.metrics.lastError = message;
|
|
18004
18370
|
op.attempts += 1;
|
|
@@ -18349,6 +18715,7 @@ function assertPostgresAdapterCapabilities(adapter) {
|
|
|
18349
18715
|
}
|
|
18350
18716
|
}
|
|
18351
18717
|
// src/storage/shadow-outbox.ts
|
|
18718
|
+
init_types();
|
|
18352
18719
|
init_shadow_outbox_schema();
|
|
18353
18720
|
init_shadow_outbox_schema();
|
|
18354
18721
|
var MAX_BACKOFF_MS = 5 * 60000;
|
|
@@ -18478,6 +18845,12 @@ class TodosShadowOutbox {
|
|
|
18478
18845
|
this.onEvent?.({ type: "mirrored", objectType: row.object_type, id: row.object_id, lagMs });
|
|
18479
18846
|
} catch (error) {
|
|
18480
18847
|
const message = error instanceof Error ? error.message : String(error);
|
|
18848
|
+
if (error instanceof ResourceConflictError) {
|
|
18849
|
+
this.metrics.lastError = message;
|
|
18850
|
+
this.db.run(`UPDATE shadow_outbox SET attempts=?, last_error=?, status='failed' WHERE seq=? AND revision=?`, [row.attempts + 1, message, row.seq, row.revision]);
|
|
18851
|
+
this.onEvent?.({ type: "parked", objectType: row.object_type, id: row.object_id, error: message });
|
|
18852
|
+
return;
|
|
18853
|
+
}
|
|
18481
18854
|
this.metrics.retries += 1;
|
|
18482
18855
|
this.metrics.lastError = message;
|
|
18483
18856
|
const attempts = row.attempts + 1;
|