@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
|
@@ -623,13 +623,15 @@ function upsertSecretSafetyConfig(input) {
|
|
|
623
623
|
saveConfig({ ...config, secret_safety: next });
|
|
624
624
|
return next;
|
|
625
625
|
}
|
|
626
|
-
var DEFAULT_SECRET_PATTERNS, DEFAULT_SECRET_KEY_PATTERN, NON_SECRET_USAGE_KEYS, REDACTION_PLACEHOLDER;
|
|
626
|
+
var XAI_PREFIX, DEFAULT_SECRET_PATTERNS, DEFAULT_SECRET_KEY_PATTERN, NON_SECRET_USAGE_KEYS, REDACTION_PLACEHOLDER;
|
|
627
627
|
var init_redaction = __esm(() => {
|
|
628
628
|
init_config();
|
|
629
|
+
XAI_PREFIX = ["x", "ai", "-"].join("");
|
|
629
630
|
DEFAULT_SECRET_PATTERNS = [
|
|
630
631
|
{ name: "aws-access-key", regex: /\b(AKIA|ASIA)[0-9A-Z]{16}\b/g, replacement: "[REDACTED_AWS_KEY]" },
|
|
631
632
|
{ name: "private-key", regex: /-----BEGIN (?:RSA |EC |OPENSSH |)PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH |)PRIVATE KEY-----/g, replacement: "[REDACTED_PRIVATE_KEY]" },
|
|
632
633
|
{ name: "openai-token", regex: /\bsk-[A-Za-z0-9_-]{12,}\b/g, replacement: "[REDACTED_TOKEN]" },
|
|
634
|
+
{ name: `${XAI_PREFIX}token`, regex: /\bxai[-][A-Za-z0-9]{20,80}\b/g, replacement: "[REDACTED_TOKEN]" },
|
|
633
635
|
{ name: "npm-token", regex: /\bnpm_[A-Za-z0-9]{20,}\b/g, replacement: "[REDACTED_NPM_TOKEN]" },
|
|
634
636
|
{ name: "github-fine-grained-token", regex: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, replacement: "[REDACTED_GITHUB_TOKEN]" },
|
|
635
637
|
{ name: "github-token", regex: /\bgh[opsu]_[A-Za-z0-9]{20,}\b/g, replacement: "[REDACTED_GITHUB_TOKEN]" },
|
|
@@ -6607,8 +6609,9 @@ var init_event_hooks = __esm(() => {
|
|
|
6607
6609
|
VALID_TARGETS = new Set(["stdout", "file", "socket", "script"]);
|
|
6608
6610
|
});
|
|
6609
6611
|
|
|
6610
|
-
// node_modules/.bun/@hasna+events@0.1.
|
|
6612
|
+
// node_modules/.bun/@hasna+events@0.1.16/node_modules/@hasna/events/dist/index.js
|
|
6611
6613
|
import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
6614
|
+
import { Buffer as Buffer2 } from "buffer";
|
|
6612
6615
|
import { existsSync as existsSync6 } from "fs";
|
|
6613
6616
|
import { homedir as homedir2 } from "os";
|
|
6614
6617
|
import { join as join4 } from "path";
|
|
@@ -6717,11 +6720,13 @@ function getEventsDataDir(override) {
|
|
|
6717
6720
|
|
|
6718
6721
|
class JsonEventsStore {
|
|
6719
6722
|
dataDir;
|
|
6723
|
+
runtime;
|
|
6720
6724
|
channelsPath;
|
|
6721
6725
|
eventsPath;
|
|
6722
6726
|
deliveriesPath;
|
|
6723
6727
|
constructor(dataDir = getEventsDataDir()) {
|
|
6724
6728
|
this.dataDir = dataDir;
|
|
6729
|
+
this.runtime = localJsonRuntime(dataDir);
|
|
6725
6730
|
this.channelsPath = join4(dataDir, "channels.json");
|
|
6726
6731
|
this.eventsPath = join4(dataDir, "events.json");
|
|
6727
6732
|
this.deliveriesPath = join4(dataDir, "deliveries.json");
|
|
@@ -6769,13 +6774,58 @@ class JsonEventsStore {
|
|
|
6769
6774
|
await this.writeJson(this.eventsPath, events);
|
|
6770
6775
|
return event;
|
|
6771
6776
|
}
|
|
6772
|
-
async
|
|
6777
|
+
async appendEventOnce(event, options = {}) {
|
|
6773
6778
|
await this.init();
|
|
6774
|
-
|
|
6779
|
+
const events = await this.readJson(this.eventsPath, []);
|
|
6780
|
+
const dedupe = options.dedupe !== false;
|
|
6781
|
+
if (dedupe) {
|
|
6782
|
+
const existing = findEventByIdentity(events, { id: event.id, dedupeKey: event.dedupeKey });
|
|
6783
|
+
if (existing) {
|
|
6784
|
+
return {
|
|
6785
|
+
event: existing,
|
|
6786
|
+
stored: false,
|
|
6787
|
+
deduped: true,
|
|
6788
|
+
identity: { id: existing.id, dedupeKey: existing.dedupeKey }
|
|
6789
|
+
};
|
|
6790
|
+
}
|
|
6791
|
+
}
|
|
6792
|
+
events.push(event);
|
|
6793
|
+
await this.writeJson(this.eventsPath, events);
|
|
6794
|
+
return {
|
|
6795
|
+
event,
|
|
6796
|
+
stored: true,
|
|
6797
|
+
deduped: false,
|
|
6798
|
+
identity: { id: event.id, dedupeKey: event.dedupeKey }
|
|
6799
|
+
};
|
|
6800
|
+
}
|
|
6801
|
+
async listEvents(options = {}) {
|
|
6802
|
+
await this.init();
|
|
6803
|
+
const events = await this.readJson(this.eventsPath, []);
|
|
6804
|
+
return queryEvents(events, options);
|
|
6805
|
+
}
|
|
6806
|
+
async listEventsPage(options = {}) {
|
|
6807
|
+
await this.init();
|
|
6808
|
+
const events = await this.readJson(this.eventsPath, []);
|
|
6809
|
+
const queried = queryEvents(events, {
|
|
6810
|
+
eventId: options.eventId,
|
|
6811
|
+
source: options.source,
|
|
6812
|
+
type: options.type
|
|
6813
|
+
});
|
|
6814
|
+
const offset = decodeLocalJsonEventCursor(options.cursor, options);
|
|
6815
|
+
const limit = normalizeEventPageLimit(options.limit);
|
|
6816
|
+
const pageEvents = queried.slice(offset, offset + limit);
|
|
6817
|
+
const nextOffset = offset + pageEvents.length;
|
|
6818
|
+
const hasMore = nextOffset < queried.length;
|
|
6819
|
+
return {
|
|
6820
|
+
events: pageEvents,
|
|
6821
|
+
cursor: options.cursor,
|
|
6822
|
+
nextCursor: hasMore ? encodeLocalJsonEventCursor(nextOffset, options) : undefined,
|
|
6823
|
+
hasMore
|
|
6824
|
+
};
|
|
6775
6825
|
}
|
|
6776
6826
|
async findEventByIdentity(identity) {
|
|
6777
6827
|
const events = await this.listEvents();
|
|
6778
|
-
return events
|
|
6828
|
+
return findEventByIdentity(events, identity);
|
|
6779
6829
|
}
|
|
6780
6830
|
async appendDelivery(result) {
|
|
6781
6831
|
await this.init();
|
|
@@ -6826,6 +6876,83 @@ class JsonEventsStore {
|
|
|
6826
6876
|
});
|
|
6827
6877
|
}
|
|
6828
6878
|
}
|
|
6879
|
+
function localJsonRuntime(dataDir = getEventsDataDir()) {
|
|
6880
|
+
return {
|
|
6881
|
+
mode: "local-files",
|
|
6882
|
+
name: "json-events-store",
|
|
6883
|
+
remote: false,
|
|
6884
|
+
localFiles: true,
|
|
6885
|
+
localSqlite: false,
|
|
6886
|
+
postgres: false,
|
|
6887
|
+
s3: false,
|
|
6888
|
+
aws: false,
|
|
6889
|
+
durable: true,
|
|
6890
|
+
idempotency: "best-effort-local",
|
|
6891
|
+
replayCursors: true,
|
|
6892
|
+
description: `Local JSON files in ${dataDir}; no SQLite, Postgres, S3, or AWS runtime is configured by this store.`
|
|
6893
|
+
};
|
|
6894
|
+
}
|
|
6895
|
+
function encodeLocalJsonEventCursor(offset, options = {}) {
|
|
6896
|
+
if (!Number.isInteger(offset) || offset < 0)
|
|
6897
|
+
throw new Error(`Invalid event cursor offset: ${offset}`);
|
|
6898
|
+
const payload = {
|
|
6899
|
+
offset,
|
|
6900
|
+
eventId: options.eventId,
|
|
6901
|
+
source: options.source,
|
|
6902
|
+
type: options.type
|
|
6903
|
+
};
|
|
6904
|
+
return `${LOCAL_JSON_EVENT_CURSOR_PREFIX}${Buffer2.from(JSON.stringify(payload), "utf-8").toString("base64url")}`;
|
|
6905
|
+
}
|
|
6906
|
+
function decodeLocalJsonEventCursor(cursor, options = {}) {
|
|
6907
|
+
if (!cursor)
|
|
6908
|
+
return 0;
|
|
6909
|
+
if (!cursor.startsWith(LOCAL_JSON_EVENT_CURSOR_PREFIX))
|
|
6910
|
+
throw new Error(`Invalid local JSON event cursor: ${cursor}`);
|
|
6911
|
+
const rawPayload = cursor.slice(LOCAL_JSON_EVENT_CURSOR_PREFIX.length);
|
|
6912
|
+
let payload;
|
|
6913
|
+
try {
|
|
6914
|
+
payload = JSON.parse(Buffer2.from(rawPayload, "base64url").toString("utf-8"));
|
|
6915
|
+
} catch {
|
|
6916
|
+
throw new Error(`Invalid local JSON event cursor: ${cursor}`);
|
|
6917
|
+
}
|
|
6918
|
+
const offset = payload.offset;
|
|
6919
|
+
if (!Number.isInteger(offset) || offset < 0)
|
|
6920
|
+
throw new Error(`Invalid local JSON event cursor: ${cursor}`);
|
|
6921
|
+
assertCursorFilter("eventId", payload.eventId, options.eventId);
|
|
6922
|
+
assertCursorFilter("source", payload.source, options.source);
|
|
6923
|
+
assertCursorFilter("type", payload.type, options.type);
|
|
6924
|
+
return offset;
|
|
6925
|
+
}
|
|
6926
|
+
function normalizeEventPageLimit(limit) {
|
|
6927
|
+
if (limit === undefined)
|
|
6928
|
+
return DEFAULT_EVENT_PAGE_LIMIT;
|
|
6929
|
+
if (!Number.isInteger(limit) || limit < 1)
|
|
6930
|
+
throw new Error(`Event page limit must be a positive integer, got ${limit}`);
|
|
6931
|
+
return Math.min(limit, MAX_EVENT_PAGE_LIMIT);
|
|
6932
|
+
}
|
|
6933
|
+
function queryEvents(events, options) {
|
|
6934
|
+
let rows = events;
|
|
6935
|
+
if (options.eventId)
|
|
6936
|
+
rows = rows.filter((event) => event.id === options.eventId);
|
|
6937
|
+
if (options.source)
|
|
6938
|
+
rows = rows.filter((event) => event.source === options.source);
|
|
6939
|
+
if (options.type)
|
|
6940
|
+
rows = rows.filter((event) => event.type === options.type);
|
|
6941
|
+
if (options.cursor) {
|
|
6942
|
+
const offset = decodeLocalJsonEventCursor(options.cursor, options);
|
|
6943
|
+
rows = rows.slice(offset);
|
|
6944
|
+
}
|
|
6945
|
+
if (options.limit !== undefined)
|
|
6946
|
+
rows = rows.slice(0, normalizeEventPageLimit(options.limit));
|
|
6947
|
+
return rows;
|
|
6948
|
+
}
|
|
6949
|
+
function assertCursorFilter(name, cursorValue, optionValue) {
|
|
6950
|
+
if (cursorValue !== optionValue)
|
|
6951
|
+
throw new Error(`Local JSON event cursor ${name} filter mismatch`);
|
|
6952
|
+
}
|
|
6953
|
+
function findEventByIdentity(events, identity) {
|
|
6954
|
+
return events.find((event) => identity.id !== undefined && event.id === identity.id || identity.dedupeKey !== undefined && event.dedupeKey === identity.dedupeKey);
|
|
6955
|
+
}
|
|
6829
6956
|
function buildSignatureBase(timestamp2, body) {
|
|
6830
6957
|
return `${timestamp2}.${body}`;
|
|
6831
6958
|
}
|
|
@@ -6839,21 +6966,27 @@ function now2() {
|
|
|
6839
6966
|
function truncate(value, max = 4096) {
|
|
6840
6967
|
return value.length > max ? `${value.slice(0, max)}...` : value;
|
|
6841
6968
|
}
|
|
6842
|
-
function buildWebhookRequest(event, channel) {
|
|
6969
|
+
function buildWebhookRequest(event, channel, options = {}) {
|
|
6843
6970
|
if (!channel.webhook)
|
|
6844
6971
|
throw new Error(`Channel ${channel.id} has no webhook config`);
|
|
6972
|
+
for (const name of Object.keys(channel.webhook.headers ?? {})) {
|
|
6973
|
+
if (/^x-hasna-/i.test(name)) {
|
|
6974
|
+
throw new Error(`Webhook header ${name} is reserved for signed delivery metadata`);
|
|
6975
|
+
}
|
|
6976
|
+
}
|
|
6845
6977
|
const body = JSON.stringify(event);
|
|
6846
|
-
const timestamp2 =
|
|
6978
|
+
const timestamp2 = options.timestamp ?? new Date().toISOString();
|
|
6847
6979
|
const headers = {
|
|
6848
6980
|
"Content-Type": "application/json",
|
|
6849
6981
|
"User-Agent": "@hasna/events",
|
|
6850
6982
|
"X-Hasna-Event-Id": event.id,
|
|
6851
6983
|
"X-Hasna-Event-Type": event.type,
|
|
6852
|
-
|
|
6853
|
-
|
|
6984
|
+
...channel.webhook.headers,
|
|
6985
|
+
"X-Hasna-Timestamp": timestamp2
|
|
6854
6986
|
};
|
|
6855
|
-
|
|
6856
|
-
|
|
6987
|
+
const secret = options.secret ?? channel.webhook.secret;
|
|
6988
|
+
if (secret) {
|
|
6989
|
+
headers["X-Hasna-Signature"] = signPayload(secret, timestamp2, body);
|
|
6857
6990
|
}
|
|
6858
6991
|
return { body, headers };
|
|
6859
6992
|
}
|
|
@@ -6861,7 +6994,21 @@ async function dispatchWebhook(event, channel, options = {}) {
|
|
|
6861
6994
|
if (!channel.webhook)
|
|
6862
6995
|
throw new Error(`Channel ${channel.id} has no webhook config`);
|
|
6863
6996
|
const startedAt = now2();
|
|
6864
|
-
|
|
6997
|
+
let secret = channel.webhook.secret;
|
|
6998
|
+
if (channel.webhook.secretRef) {
|
|
6999
|
+
if (!options.secretResolver) {
|
|
7000
|
+
return failedAttempt(startedAt, "Webhook secret reference has no runtime resolver");
|
|
7001
|
+
}
|
|
7002
|
+
try {
|
|
7003
|
+
secret = await options.secretResolver(channel.webhook.secretRef);
|
|
7004
|
+
} catch {
|
|
7005
|
+
return failedAttempt(startedAt, "Webhook secret reference could not be resolved");
|
|
7006
|
+
}
|
|
7007
|
+
if (!secret)
|
|
7008
|
+
return failedAttempt(startedAt, "Webhook secret reference could not be resolved");
|
|
7009
|
+
}
|
|
7010
|
+
const timestamp2 = (options.now?.() ?? new Date).toISOString();
|
|
7011
|
+
const { body, headers } = buildWebhookRequest(event, channel, { secret, timestamp: timestamp2 });
|
|
6865
7012
|
const controller = new AbortController;
|
|
6866
7013
|
const timeout = setTimeout(() => controller.abort(), channel.webhook.timeoutMs ?? 15000);
|
|
6867
7014
|
try {
|
|
@@ -6893,6 +7040,15 @@ async function dispatchWebhook(event, channel, options = {}) {
|
|
|
6893
7040
|
clearTimeout(timeout);
|
|
6894
7041
|
}
|
|
6895
7042
|
}
|
|
7043
|
+
function failedAttempt(startedAt, error) {
|
|
7044
|
+
return {
|
|
7045
|
+
attempt: 1,
|
|
7046
|
+
status: "failed",
|
|
7047
|
+
startedAt,
|
|
7048
|
+
completedAt: now2(),
|
|
7049
|
+
error
|
|
7050
|
+
};
|
|
7051
|
+
}
|
|
6896
7052
|
async function dispatchCommand(event, channel) {
|
|
6897
7053
|
if (!channel.command)
|
|
6898
7054
|
throw new Error(`Channel ${channel.id} has no command config`);
|
|
@@ -6981,6 +7137,76 @@ function createDeliveryResult(event, channel, attempts) {
|
|
|
6981
7137
|
completedAt: attempts.at(-1)?.completedAt ?? now2()
|
|
6982
7138
|
};
|
|
6983
7139
|
}
|
|
7140
|
+
|
|
7141
|
+
class EventTypeCatalog {
|
|
7142
|
+
definitions = new Map;
|
|
7143
|
+
register(definition) {
|
|
7144
|
+
this.definitions.set(definition.type, definition);
|
|
7145
|
+
return this;
|
|
7146
|
+
}
|
|
7147
|
+
unregister(type) {
|
|
7148
|
+
return this.definitions.delete(type);
|
|
7149
|
+
}
|
|
7150
|
+
has(type) {
|
|
7151
|
+
return this.definitions.has(type);
|
|
7152
|
+
}
|
|
7153
|
+
get(type) {
|
|
7154
|
+
return this.definitions.get(type);
|
|
7155
|
+
}
|
|
7156
|
+
list() {
|
|
7157
|
+
return [...this.definitions.values()];
|
|
7158
|
+
}
|
|
7159
|
+
validateEvent(event) {
|
|
7160
|
+
const definition = this.definitions.get(event.type);
|
|
7161
|
+
if (!definition)
|
|
7162
|
+
return { ok: true };
|
|
7163
|
+
return definition.validate(event.data, event);
|
|
7164
|
+
}
|
|
7165
|
+
assertEventValid(event) {
|
|
7166
|
+
const result = this.validateEvent(event);
|
|
7167
|
+
if (!result.ok) {
|
|
7168
|
+
throw new EventValidationError(event.type, result.issues);
|
|
7169
|
+
}
|
|
7170
|
+
}
|
|
7171
|
+
}
|
|
7172
|
+
function redactPaths(event, paths, replacement = "[REDACTED]") {
|
|
7173
|
+
if (paths.length === 0)
|
|
7174
|
+
return event;
|
|
7175
|
+
const copy = structuredClone(event);
|
|
7176
|
+
for (const path of paths) {
|
|
7177
|
+
setPath(copy, path, replacement);
|
|
7178
|
+
}
|
|
7179
|
+
return copy;
|
|
7180
|
+
}
|
|
7181
|
+
function redactSensitiveKeys(event, replacement = "[REDACTED]") {
|
|
7182
|
+
return redactValue2(event, replacement);
|
|
7183
|
+
}
|
|
7184
|
+
function shouldRedactKey(key) {
|
|
7185
|
+
return /secret|token|password|api[_-]?key|authorization/i.test(key);
|
|
7186
|
+
}
|
|
7187
|
+
function redactValue2(value, replacement) {
|
|
7188
|
+
if (Array.isArray(value))
|
|
7189
|
+
return value.map((item) => redactValue2(item, replacement));
|
|
7190
|
+
if (!value || typeof value !== "object")
|
|
7191
|
+
return value;
|
|
7192
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
7193
|
+
key,
|
|
7194
|
+
shouldRedactKey(key) ? replacement : redactValue2(item, replacement)
|
|
7195
|
+
]));
|
|
7196
|
+
}
|
|
7197
|
+
function setPath(input, path, replacement) {
|
|
7198
|
+
const parts = path.split(".");
|
|
7199
|
+
let cursor = input;
|
|
7200
|
+
for (const part of parts.slice(0, -1)) {
|
|
7201
|
+
const next = cursor[part];
|
|
7202
|
+
if (!next || typeof next !== "object")
|
|
7203
|
+
return;
|
|
7204
|
+
cursor = next;
|
|
7205
|
+
}
|
|
7206
|
+
const last = parts.at(-1);
|
|
7207
|
+
if (last && last in cursor)
|
|
7208
|
+
cursor[last] = replacement;
|
|
7209
|
+
}
|
|
6984
7210
|
function createEvent(input) {
|
|
6985
7211
|
return {
|
|
6986
7212
|
id: input.id ?? randomUUID22(),
|
|
@@ -7001,10 +7227,18 @@ class EventsClient {
|
|
|
7001
7227
|
store;
|
|
7002
7228
|
redactors;
|
|
7003
7229
|
transportOptions;
|
|
7230
|
+
catalog;
|
|
7231
|
+
validateCatalogTypes;
|
|
7004
7232
|
constructor(options = {}) {
|
|
7005
7233
|
this.store = options.store ?? new JsonEventsStore(options.dataDir);
|
|
7006
7234
|
this.redactors = options.redactors ?? [];
|
|
7007
|
-
this.transportOptions = {
|
|
7235
|
+
this.transportOptions = {
|
|
7236
|
+
fetchImpl: options.fetchImpl,
|
|
7237
|
+
secretResolver: options.secretResolver,
|
|
7238
|
+
now: options.now
|
|
7239
|
+
};
|
|
7240
|
+
this.catalog = options.catalog ?? defaultEventTypeCatalog;
|
|
7241
|
+
this.validateCatalogTypes = options.validateCatalogTypes ?? false;
|
|
7008
7242
|
}
|
|
7009
7243
|
async addChannel(input) {
|
|
7010
7244
|
const timestamp2 = new Date().toISOString();
|
|
@@ -7022,18 +7256,40 @@ class EventsClient {
|
|
|
7022
7256
|
}
|
|
7023
7257
|
async emit(input, options = {}) {
|
|
7024
7258
|
const event = options.redactSensitiveData === false ? createEvent(input) : redactSensitiveKeys(createEvent(input));
|
|
7025
|
-
if (options.
|
|
7026
|
-
|
|
7027
|
-
|
|
7028
|
-
|
|
7029
|
-
|
|
7030
|
-
|
|
7031
|
-
|
|
7032
|
-
const deliveries = options.deliver === false ? [] : await this.deliver(event);
|
|
7033
|
-
return { event, deliveries, deduped: false };
|
|
7034
|
-
}
|
|
7035
|
-
async listEvents() {
|
|
7036
|
-
|
|
7259
|
+
if (options.validate ?? this.validateCatalogTypes) {
|
|
7260
|
+
this.catalog.assertEventValid(event);
|
|
7261
|
+
}
|
|
7262
|
+
const append = await this.appendEvent(event, { dedupe: options.dedupe !== false });
|
|
7263
|
+
if (append.deduped) {
|
|
7264
|
+
return { event: append.event, deliveries: [], deduped: true };
|
|
7265
|
+
}
|
|
7266
|
+
const deliveries = options.deliver === false ? [] : await this.deliver(append.event);
|
|
7267
|
+
return { event: append.event, deliveries, deduped: false };
|
|
7268
|
+
}
|
|
7269
|
+
async listEvents(options = {}) {
|
|
7270
|
+
if (Object.keys(options).length === 0)
|
|
7271
|
+
return this.store.listEvents();
|
|
7272
|
+
return queryClientEvents(await this.store.listEvents(), options);
|
|
7273
|
+
}
|
|
7274
|
+
async listEventsPage(options = {}) {
|
|
7275
|
+
if (this.store.listEventsPage)
|
|
7276
|
+
return this.store.listEventsPage(options);
|
|
7277
|
+
const events = queryClientEvents(await this.store.listEvents(), {
|
|
7278
|
+
eventId: options.eventId,
|
|
7279
|
+
source: options.source,
|
|
7280
|
+
type: options.type
|
|
7281
|
+
});
|
|
7282
|
+
const offset = decodeLocalJsonEventCursor(options.cursor, options);
|
|
7283
|
+
const limit = normalizeEventPageLimit(options.limit);
|
|
7284
|
+
const pageEvents = events.slice(offset, offset + limit);
|
|
7285
|
+
const nextOffset = offset + pageEvents.length;
|
|
7286
|
+
const hasMore = nextOffset < events.length;
|
|
7287
|
+
return {
|
|
7288
|
+
events: pageEvents,
|
|
7289
|
+
cursor: options.cursor,
|
|
7290
|
+
nextCursor: hasMore ? encodeLocalJsonEventCursor(nextOffset, options) : undefined,
|
|
7291
|
+
hasMore
|
|
7292
|
+
};
|
|
7037
7293
|
}
|
|
7038
7294
|
async listDeliveries() {
|
|
7039
7295
|
return this.store.listDeliveries();
|
|
@@ -7101,22 +7357,37 @@ class EventsClient {
|
|
|
7101
7357
|
return result;
|
|
7102
7358
|
}
|
|
7103
7359
|
async replay(options = {}) {
|
|
7104
|
-
const
|
|
7105
|
-
if (options.eventId && event.id !== options.eventId)
|
|
7106
|
-
return false;
|
|
7107
|
-
if (options.source && event.source !== options.source)
|
|
7108
|
-
return false;
|
|
7109
|
-
if (options.type && event.type !== options.type)
|
|
7110
|
-
return false;
|
|
7111
|
-
return true;
|
|
7112
|
-
});
|
|
7360
|
+
const page = options.cursor || options.limit !== undefined ? await this.listEventsPage(options) : { events: await this.listEvents(options), hasMore: false };
|
|
7113
7361
|
if (options.dryRun)
|
|
7114
|
-
return { events, deliveries: [] };
|
|
7362
|
+
return { events: page.events, deliveries: [], cursor: page.cursor, nextCursor: page.nextCursor, hasMore: page.hasMore };
|
|
7115
7363
|
const deliveries = [];
|
|
7116
|
-
for (const event of events) {
|
|
7364
|
+
for (const event of page.events) {
|
|
7117
7365
|
deliveries.push(...await this.deliver(event));
|
|
7118
7366
|
}
|
|
7119
|
-
return { events, deliveries };
|
|
7367
|
+
return { events: page.events, deliveries, cursor: page.cursor, nextCursor: page.nextCursor, hasMore: page.hasMore };
|
|
7368
|
+
}
|
|
7369
|
+
async appendEvent(event, options) {
|
|
7370
|
+
if (this.store.appendEventOnce) {
|
|
7371
|
+
return this.store.appendEventOnce(event, { dedupe: options.dedupe });
|
|
7372
|
+
}
|
|
7373
|
+
if (options.dedupe) {
|
|
7374
|
+
const existing = await this.store.findEventByIdentity({ id: event.id, dedupeKey: event.dedupeKey });
|
|
7375
|
+
if (existing) {
|
|
7376
|
+
return {
|
|
7377
|
+
event: existing,
|
|
7378
|
+
stored: false,
|
|
7379
|
+
deduped: true,
|
|
7380
|
+
identity: { id: existing.id, dedupeKey: existing.dedupeKey }
|
|
7381
|
+
};
|
|
7382
|
+
}
|
|
7383
|
+
}
|
|
7384
|
+
const stored = await this.store.appendEvent(event);
|
|
7385
|
+
return {
|
|
7386
|
+
event: stored,
|
|
7387
|
+
stored: true,
|
|
7388
|
+
deduped: false,
|
|
7389
|
+
identity: { id: stored.id, dedupeKey: stored.dedupeKey }
|
|
7390
|
+
};
|
|
7120
7391
|
}
|
|
7121
7392
|
async applyRedaction(event, channel) {
|
|
7122
7393
|
let next = redactPaths(event, channel.redact?.paths ?? [], channel.redact?.replacement ?? "[REDACTED]");
|
|
@@ -7143,43 +7414,19 @@ class EventsClient {
|
|
|
7143
7414
|
return createDeliveryResult(event, channel, attempts);
|
|
7144
7415
|
}
|
|
7145
7416
|
}
|
|
7146
|
-
function
|
|
7147
|
-
|
|
7148
|
-
|
|
7149
|
-
|
|
7150
|
-
|
|
7151
|
-
|
|
7152
|
-
|
|
7153
|
-
|
|
7154
|
-
|
|
7155
|
-
|
|
7156
|
-
|
|
7157
|
-
|
|
7158
|
-
|
|
7159
|
-
return /secret|token|password|api[_-]?key|authorization/i.test(key);
|
|
7160
|
-
}
|
|
7161
|
-
function redactValue2(value, replacement) {
|
|
7162
|
-
if (Array.isArray(value))
|
|
7163
|
-
return value.map((item) => redactValue2(item, replacement));
|
|
7164
|
-
if (!value || typeof value !== "object")
|
|
7165
|
-
return value;
|
|
7166
|
-
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
7167
|
-
key,
|
|
7168
|
-
shouldRedactKey(key) ? replacement : redactValue2(item, replacement)
|
|
7169
|
-
]));
|
|
7170
|
-
}
|
|
7171
|
-
function setPath(input, path, replacement) {
|
|
7172
|
-
const parts = path.split(".");
|
|
7173
|
-
let cursor = input;
|
|
7174
|
-
for (const part of parts.slice(0, -1)) {
|
|
7175
|
-
const next = cursor[part];
|
|
7176
|
-
if (!next || typeof next !== "object")
|
|
7177
|
-
return;
|
|
7178
|
-
cursor = next;
|
|
7179
|
-
}
|
|
7180
|
-
const last = parts.at(-1);
|
|
7181
|
-
if (last && last in cursor)
|
|
7182
|
-
cursor[last] = replacement;
|
|
7417
|
+
function queryClientEvents(events, options) {
|
|
7418
|
+
let rows = events;
|
|
7419
|
+
if (options.eventId)
|
|
7420
|
+
rows = rows.filter((event) => event.id === options.eventId);
|
|
7421
|
+
if (options.source)
|
|
7422
|
+
rows = rows.filter((event) => event.source === options.source);
|
|
7423
|
+
if (options.type)
|
|
7424
|
+
rows = rows.filter((event) => event.type === options.type);
|
|
7425
|
+
if (options.cursor)
|
|
7426
|
+
rows = rows.slice(decodeLocalJsonEventCursor(options.cursor, options));
|
|
7427
|
+
if (options.limit !== undefined)
|
|
7428
|
+
rows = rows.slice(0, normalizeEventPageLimit(options.limit));
|
|
7429
|
+
return rows;
|
|
7183
7430
|
}
|
|
7184
7431
|
function normalizeTime(value) {
|
|
7185
7432
|
if (!value)
|
|
@@ -7193,9 +7440,22 @@ function normalizeRetryPolicy(policy) {
|
|
|
7193
7440
|
multiplier: Math.max(1, policy?.multiplier ?? 2)
|
|
7194
7441
|
};
|
|
7195
7442
|
}
|
|
7196
|
-
var HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR", HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME", DEFAULT_SIGNATURE_TOLERANCE_MS;
|
|
7443
|
+
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;
|
|
7197
7444
|
var init_dist = __esm(() => {
|
|
7198
7445
|
DEFAULT_SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
|
|
7446
|
+
EventValidationError = class EventValidationError extends Error {
|
|
7447
|
+
eventType;
|
|
7448
|
+
issues;
|
|
7449
|
+
constructor(eventType, issues) {
|
|
7450
|
+
const detail = issues.map((issue) => `${issue.path || "<root>"}: ${issue.message}`).join("; ");
|
|
7451
|
+
super(`Event validation failed for type "${eventType}": ${detail}`);
|
|
7452
|
+
this.name = "EventValidationError";
|
|
7453
|
+
this.eventType = eventType;
|
|
7454
|
+
this.issues = issues;
|
|
7455
|
+
}
|
|
7456
|
+
};
|
|
7457
|
+
defaultEventTypeCatalog = new EventTypeCatalog;
|
|
7458
|
+
APP_EVENT_V1_MAX_DATA_BYTES = 32 * 1024;
|
|
7199
7459
|
});
|
|
7200
7460
|
|
|
7201
7461
|
// src/db/task-lists.ts
|
|
@@ -8070,8 +8330,12 @@ async function deliverWebhook(wh, event, body, attempt, db) {
|
|
|
8070
8330
|
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(body));
|
|
8071
8331
|
headers["X-Webhook-Signature"] = Array.from(new Uint8Array(sig)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
8072
8332
|
}
|
|
8073
|
-
const resp = await fetch(wh.url, { method: "POST", headers, body });
|
|
8333
|
+
const resp = await fetch(wh.url, { method: "POST", headers, body, redirect: "manual" });
|
|
8074
8334
|
const respText = await resp.text().catch(() => "");
|
|
8335
|
+
if (resp.status >= 300 && resp.status < 400) {
|
|
8336
|
+
logDelivery(db, wh.id, event, body, resp.status, "Blocked: webhook URL returned a 3xx redirect; redirects are never followed (SSRF prevention)", attempt);
|
|
8337
|
+
return;
|
|
8338
|
+
}
|
|
8075
8339
|
logDelivery(db, wh.id, event, body, resp.status, respText.slice(0, 1000), attempt);
|
|
8076
8340
|
if (resp.status >= 400 && attempt < MAX_RETRY_ATTEMPTS) {
|
|
8077
8341
|
const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1);
|
|
@@ -9671,6 +9935,12 @@ function listTasks2(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();
|
|
@@ -12812,7 +13083,7 @@ import { createHash as createHash7 } from "crypto";
|
|
|
12812
13083
|
// package.json
|
|
12813
13084
|
var package_default = {
|
|
12814
13085
|
name: "@hasna/todos",
|
|
12815
|
-
version: "0.15.
|
|
13086
|
+
version: "0.15.49",
|
|
12816
13087
|
description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
|
|
12817
13088
|
type: "module",
|
|
12818
13089
|
main: "dist/index.js",
|
|
@@ -12876,6 +13147,7 @@ var package_default = {
|
|
|
12876
13147
|
],
|
|
12877
13148
|
scripts: {
|
|
12878
13149
|
build: "rm -rf dist dashboard/dist && cd dashboard && bun install --frozen-lockfile && bun run build && cd .. && bun build src/cli/index.tsx --outdir dist/cli --target bun --external ink --external react --external chalk --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/mcp/index.ts --outdir dist/mcp --target bun --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/server/index.ts --outdir dist/server --target bun && bun build src/sdk/index.ts --outdir dist/sdk --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/index.ts src/mcp.ts src/registry.ts src/contracts.ts src/storage.ts src/testing.ts src/project-registration.ts src/task-manifest.ts src/task-subtree-transfer.ts --outdir dist --root src --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && tsc --emitDeclarationOnly --outDir dist",
|
|
13150
|
+
"build:js": "rm -rf dist && bun build src/cli/index.tsx --outdir dist/cli --target bun --external ink --external react --external chalk --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/mcp/index.ts --outdir dist/mcp --target bun --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/server/index.ts --outdir dist/server --target bun && bun build src/sdk/index.ts --outdir dist/sdk --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/index.ts src/mcp.ts src/registry.ts src/contracts.ts src/storage.ts src/testing.ts src/project-registration.ts src/task-manifest.ts src/task-subtree-transfer.ts --outdir dist --root src --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && tsc --emitDeclarationOnly --outDir dist",
|
|
12879
13151
|
"build:server": "rm -rf dist && bun build src/cli/index.tsx --outdir dist/cli --target bun --external ink --external react --external chalk --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/mcp/index.ts --outdir dist/mcp --target bun --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/server/index.ts --outdir dist/server --target bun && bun build src/sdk/index.ts --outdir dist/sdk --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/index.ts src/mcp.ts src/registry.ts src/contracts.ts src/storage.ts src/testing.ts src/project-registration.ts src/task-manifest.ts src/task-subtree-transfer.ts --outdir dist --target bun --external '@hasna/contracts' --external '@hasna/contracts/*'",
|
|
12880
13152
|
migrate: "bun run src/server/index.ts migrate",
|
|
12881
13153
|
"backfill:comment-redaction": "bun run src/server/index.ts redact-comments",
|
|
@@ -12930,18 +13202,19 @@ var package_default = {
|
|
|
12930
13202
|
author: "Andrei Hasna <andrei@hasna.com>",
|
|
12931
13203
|
license: "Apache-2.0",
|
|
12932
13204
|
dependencies: {
|
|
12933
|
-
"@hasna/contracts": "0.
|
|
13205
|
+
"@hasna/contracts": "0.14.0",
|
|
12934
13206
|
"@hasna/events": "^0.1.11",
|
|
12935
13207
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
12936
13208
|
chalk: "^5.4.1",
|
|
12937
13209
|
commander: "^13.1.0",
|
|
12938
13210
|
ink: "^5.2.0",
|
|
12939
13211
|
react: "^18.3.1",
|
|
12940
|
-
zod: "
|
|
13212
|
+
zod: "3.25.76"
|
|
12941
13213
|
},
|
|
12942
13214
|
overrides: {
|
|
12943
13215
|
ajv: "8.20.0",
|
|
12944
|
-
"fast-uri": "3.1.2"
|
|
13216
|
+
"fast-uri": "3.1.2",
|
|
13217
|
+
zod: "3.25.76"
|
|
12945
13218
|
},
|
|
12946
13219
|
devDependencies: {
|
|
12947
13220
|
"@types/bun": "^1.2.4",
|
|
@@ -13078,6 +13351,30 @@ function assertPlanProjectLinkReceipt(value) {
|
|
|
13078
13351
|
// src/storage/postgres-adapter.ts
|
|
13079
13352
|
init_stale_lock_handoff();
|
|
13080
13353
|
|
|
13354
|
+
// src/storage/postgres-sync.ts
|
|
13355
|
+
init_types();
|
|
13356
|
+
|
|
13357
|
+
// src/storage/postgres-errors.ts
|
|
13358
|
+
function isPostgresUniqueViolation(error) {
|
|
13359
|
+
if (typeof error !== "object" || error === null)
|
|
13360
|
+
return false;
|
|
13361
|
+
const candidate = error;
|
|
13362
|
+
const states = [candidate.code, candidate.errno, candidate.sqlState, candidate.sqlstate];
|
|
13363
|
+
if (typeof candidate.cause === "object" && candidate.cause !== null) {
|
|
13364
|
+
const cause = candidate.cause;
|
|
13365
|
+
states.push(cause.code, cause.errno, cause.sqlState, cause.sqlstate);
|
|
13366
|
+
}
|
|
13367
|
+
return states.some((state) => String(state) === "23505");
|
|
13368
|
+
}
|
|
13369
|
+
function postgresConstraintName(error) {
|
|
13370
|
+
if (typeof error !== "object" || error === null)
|
|
13371
|
+
return "";
|
|
13372
|
+
const candidate = error;
|
|
13373
|
+
const cause = typeof candidate.cause === "object" && candidate.cause !== null ? candidate.cause : undefined;
|
|
13374
|
+
const constraint = candidate.constraint ?? candidate.constraint_name ?? cause?.constraint ?? cause?.constraint_name;
|
|
13375
|
+
return typeof constraint === "string" ? constraint : "";
|
|
13376
|
+
}
|
|
13377
|
+
|
|
13081
13378
|
// src/storage/postgres-sync.ts
|
|
13082
13379
|
var DEFAULT_TODOS_POSTGRES_SYNC_TABLE = "todos_sync_records";
|
|
13083
13380
|
var DEFAULT_TODOS_POSTGRES_CURSOR_TABLE = "todos_sync_cursors";
|
|
@@ -13280,53 +13577,98 @@ class PostgresTodosSyncStore {
|
|
|
13280
13577
|
if (routingErrors.length > 0) {
|
|
13281
13578
|
throw new Error(`Invalid snapshot routing metadata: ${routingErrors.join("; ")}`);
|
|
13282
13579
|
}
|
|
13283
|
-
const
|
|
13284
|
-
|
|
13285
|
-
|
|
13286
|
-
|
|
13287
|
-
|
|
13288
|
-
|
|
13289
|
-
const
|
|
13290
|
-
|
|
13291
|
-
|
|
13292
|
-
|
|
13293
|
-
|
|
13294
|
-
|
|
13295
|
-
|
|
13296
|
-
|
|
13297
|
-
|
|
13298
|
-
|
|
13299
|
-
|
|
13300
|
-
|
|
13301
|
-
|
|
13302
|
-
|
|
13303
|
-
|
|
13304
|
-
|
|
13305
|
-
|
|
13306
|
-
|
|
13307
|
-
|
|
13308
|
-
|
|
13309
|
-
|
|
13310
|
-
|
|
13311
|
-
|
|
13312
|
-
|
|
13313
|
-
|
|
13314
|
-
|
|
13315
|
-
|
|
13316
|
-
|
|
13317
|
-
|
|
13318
|
-
|
|
13319
|
-
|
|
13320
|
-
|
|
13321
|
-
|
|
13322
|
-
|
|
13323
|
-
|
|
13324
|
-
|
|
13325
|
-
|
|
13326
|
-
|
|
13327
|
-
|
|
13580
|
+
const push = async (client) => {
|
|
13581
|
+
const existing = await client.query(`SELECT object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version
|
|
13582
|
+
FROM ${this.tableName}
|
|
13583
|
+
WHERE service = $1 AND object_type IN ($2, $3) AND deleted_at IS NULL`, [this.service, "projects", "task_lists"]);
|
|
13584
|
+
const existingProjects = [];
|
|
13585
|
+
const existingTaskLists = [];
|
|
13586
|
+
for (const row of existing.rows) {
|
|
13587
|
+
const payload = payloadRecord(row.payload);
|
|
13588
|
+
if (row.object_type === "projects")
|
|
13589
|
+
existingProjects.push(payload);
|
|
13590
|
+
if (row.object_type === "task_lists")
|
|
13591
|
+
existingTaskLists.push(payload);
|
|
13592
|
+
}
|
|
13593
|
+
const destinationErrors = validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists);
|
|
13594
|
+
if (destinationErrors.length > 0) {
|
|
13595
|
+
throw new ResourceConflictError("SNAPSHOT_DESTINATION_CONFLICT", `Snapshot routing conflicts with destination: ${destinationErrors.join("; ")}`);
|
|
13596
|
+
}
|
|
13597
|
+
const result = { records: 0, objectTypes: {} };
|
|
13598
|
+
const sourceMachineId = context.requestId ?? this.sourceMachineId ?? null;
|
|
13599
|
+
for (const entry of snapshotEntries(snapshot)) {
|
|
13600
|
+
if (entry.deletedAt === null)
|
|
13601
|
+
assertCanonicalScopedSlugEntry(entry);
|
|
13602
|
+
try {
|
|
13603
|
+
await client.query(`INSERT INTO ${this.tableName} (
|
|
13604
|
+
service, object_type, object_id, payload, updated_at,
|
|
13605
|
+
deleted_at, source_machine_id, version
|
|
13606
|
+
) VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, $6::timestamptz, $7, $8)
|
|
13607
|
+
ON CONFLICT (service, object_type, object_id) DO UPDATE SET
|
|
13608
|
+
payload = EXCLUDED.payload,
|
|
13609
|
+
updated_at = EXCLUDED.updated_at,
|
|
13610
|
+
deleted_at = EXCLUDED.deleted_at,
|
|
13611
|
+
source_machine_id = EXCLUDED.source_machine_id,
|
|
13612
|
+
version = EXCLUDED.version
|
|
13613
|
+
WHERE ${this.tableName}.updated_at < EXCLUDED.updated_at
|
|
13614
|
+
OR (${this.tableName}.updated_at = EXCLUDED.updated_at
|
|
13615
|
+
AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))`, [
|
|
13616
|
+
this.service,
|
|
13617
|
+
entry.type,
|
|
13618
|
+
entry.id,
|
|
13619
|
+
entry.payload,
|
|
13620
|
+
entry.updatedAt,
|
|
13621
|
+
entry.deletedAt,
|
|
13622
|
+
sourceMachineId,
|
|
13623
|
+
entry.version
|
|
13624
|
+
]);
|
|
13625
|
+
} catch (error) {
|
|
13626
|
+
await this.classifySyncInsertConflict(error, entry);
|
|
13627
|
+
}
|
|
13628
|
+
result.records += 1;
|
|
13629
|
+
result.objectTypes[entry.type] = (result.objectTypes[entry.type] ?? 0) + 1;
|
|
13630
|
+
}
|
|
13631
|
+
return result;
|
|
13632
|
+
};
|
|
13633
|
+
if (typeof this.client.transaction === "function") {
|
|
13634
|
+
return this.client.transaction((client) => push(client));
|
|
13328
13635
|
}
|
|
13329
|
-
return
|
|
13636
|
+
return push(this.client);
|
|
13637
|
+
}
|
|
13638
|
+
async classifySyncInsertConflict(error, entry) {
|
|
13639
|
+
if (!isPostgresUniqueViolation(error))
|
|
13640
|
+
throw error;
|
|
13641
|
+
const payload = entry.payload;
|
|
13642
|
+
const taskListSlug = String(payload["slug"] ?? "");
|
|
13643
|
+
const projectSlug = String(payload["task_list_id"] ?? "");
|
|
13644
|
+
const constraintName = postgresConstraintName(error);
|
|
13645
|
+
if (entry.type === "projects" && constraintName.includes("project_task_list_slug_uidx")) {
|
|
13646
|
+
throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${projectSlug}" already exists`);
|
|
13647
|
+
}
|
|
13648
|
+
if (entry.type === "task_lists" && constraintName.includes("task_list_scope_slug_uidx")) {
|
|
13649
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${taskListSlug}" already exists in this scope`);
|
|
13650
|
+
}
|
|
13651
|
+
if (entry.type === "task_lists") {
|
|
13652
|
+
const scope = typeof payload["project_id"] === "string" ? payload["project_id"] : "";
|
|
13653
|
+
const conflict = await this.client.query(`/* todos:classify-sync-task-list-conflict */ SELECT EXISTS (
|
|
13654
|
+
SELECT 1 FROM ${this.tableName}
|
|
13655
|
+
WHERE service = $1 AND object_type = 'task_lists' AND object_id <> $2
|
|
13656
|
+
AND deleted_at IS NULL AND COALESCE(payload->>'project_id','') = $3 AND payload->>'slug' = $4
|
|
13657
|
+
) AS conflict`, [this.service, entry.id, scope, taskListSlug]);
|
|
13658
|
+
if (conflict.rows[0]?.conflict) {
|
|
13659
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${taskListSlug}" already exists in this scope`);
|
|
13660
|
+
}
|
|
13661
|
+
} else if (entry.type === "projects") {
|
|
13662
|
+
const conflict = await this.client.query(`/* todos:classify-sync-project-conflict */ SELECT EXISTS (
|
|
13663
|
+
SELECT 1 FROM ${this.tableName}
|
|
13664
|
+
WHERE service = $1 AND object_type = 'projects' AND object_id <> $2
|
|
13665
|
+
AND deleted_at IS NULL AND payload->>'task_list_id' = $3
|
|
13666
|
+
) AS conflict`, [this.service, entry.id, projectSlug]);
|
|
13667
|
+
if (conflict.rows[0]?.conflict) {
|
|
13668
|
+
throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${projectSlug}" already exists`);
|
|
13669
|
+
}
|
|
13670
|
+
}
|
|
13671
|
+
throw error;
|
|
13330
13672
|
}
|
|
13331
13673
|
async pullSnapshot(options = {}) {
|
|
13332
13674
|
const params = [this.service];
|
|
@@ -13957,9 +14299,11 @@ class PostgresJsonRecordStore {
|
|
|
13957
14299
|
async ensureSchema() {
|
|
13958
14300
|
if (!this.schemaReady) {
|
|
13959
14301
|
this.schemaReady = (async () => {
|
|
13960
|
-
|
|
13961
|
-
|
|
13962
|
-
|
|
14302
|
+
await retryOnTransientPostgresError(async () => {
|
|
14303
|
+
for (const sql of postgresTodosSyncSchemaSql(this.tableName, this.cursorTableName)) {
|
|
14304
|
+
await this.options.client.query(sql);
|
|
14305
|
+
}
|
|
14306
|
+
});
|
|
13963
14307
|
})().catch((error) => {
|
|
13964
14308
|
this.schemaReady = null;
|
|
13965
14309
|
throw error;
|
|
@@ -14373,13 +14717,14 @@ class PostgresJsonRecordStore {
|
|
|
14373
14717
|
throw new Error(divergentAuditHistoryReplayError(value.id));
|
|
14374
14718
|
}
|
|
14375
14719
|
async withTaskParentIntegrityTransaction(fn) {
|
|
14376
|
-
|
|
14720
|
+
const transaction = this.options.client.transaction;
|
|
14721
|
+
if (typeof transaction !== "function") {
|
|
14377
14722
|
throw new Error("TASK_PARENT_ATOMICITY_UNAVAILABLE: PostgreSQL parent writes and task deletion require transaction(callback)");
|
|
14378
14723
|
}
|
|
14379
|
-
return
|
|
14724
|
+
return retryOnTransientPostgresError(() => transaction(async (client) => {
|
|
14380
14725
|
await client.query("/* todos:task-parent-integrity-lock */ SELECT pg_advisory_xact_lock(hashtextextended($1 || ':task-parent-integrity', 0))", [this.service]);
|
|
14381
14726
|
return fn(client);
|
|
14382
|
-
});
|
|
14727
|
+
}));
|
|
14383
14728
|
}
|
|
14384
14729
|
async upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context = {}, parentGuard, queryClient) {
|
|
14385
14730
|
const planIds = [...new Set(guardedPlanIds.filter(Boolean))].sort();
|
|
@@ -16265,7 +16610,13 @@ function compareClock(left, right) {
|
|
|
16265
16610
|
function numberValue2(value) {
|
|
16266
16611
|
return typeof value === "number" && Number.isSafeInteger(value) ? value : null;
|
|
16267
16612
|
}
|
|
16268
|
-
|
|
16613
|
+
var TRANSIENT_POSTGRES_SQLSTATES = new Set(["55P03", "40P01", "40001"]);
|
|
16614
|
+
var TRANSIENT_POSTGRES_MESSAGE_MARKERS = [
|
|
16615
|
+
"canceling statement due to lock timeout",
|
|
16616
|
+
"deadlock detected",
|
|
16617
|
+
"could not serialize access"
|
|
16618
|
+
];
|
|
16619
|
+
function isTransientPostgresError(error) {
|
|
16269
16620
|
if (typeof error !== "object" || error === null)
|
|
16270
16621
|
return false;
|
|
16271
16622
|
const candidate = error;
|
|
@@ -16274,15 +16625,25 @@ function isPostgresUniqueViolation(error) {
|
|
|
16274
16625
|
const cause = candidate.cause;
|
|
16275
16626
|
states.push(cause.code, cause.errno, cause.sqlState, cause.sqlstate);
|
|
16276
16627
|
}
|
|
16277
|
-
|
|
16628
|
+
if (states.some((state) => typeof state === "string" && TRANSIENT_POSTGRES_SQLSTATES.has(state))) {
|
|
16629
|
+
return true;
|
|
16630
|
+
}
|
|
16631
|
+
const message = typeof candidate.message === "string" ? candidate.message : candidate.cause?.message;
|
|
16632
|
+
return typeof message === "string" && TRANSIENT_POSTGRES_MESSAGE_MARKERS.some((marker) => message.includes(marker));
|
|
16278
16633
|
}
|
|
16279
|
-
function
|
|
16280
|
-
|
|
16281
|
-
|
|
16282
|
-
|
|
16283
|
-
|
|
16284
|
-
|
|
16285
|
-
|
|
16634
|
+
async function retryOnTransientPostgresError(fn, attempts = 2, delayMs = 150) {
|
|
16635
|
+
let lastError;
|
|
16636
|
+
for (let attempt = 1;attempt <= attempts; attempt += 1) {
|
|
16637
|
+
try {
|
|
16638
|
+
return await fn();
|
|
16639
|
+
} catch (error) {
|
|
16640
|
+
lastError = error;
|
|
16641
|
+
if (!isTransientPostgresError(error) || attempt === attempts)
|
|
16642
|
+
throw error;
|
|
16643
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs * attempt));
|
|
16644
|
+
}
|
|
16645
|
+
}
|
|
16646
|
+
throw lastError;
|
|
16286
16647
|
}
|
|
16287
16648
|
// src/project-registration/types.ts
|
|
16288
16649
|
var TODOS_PROJECT_REGISTRATION_ROUTE = "todos.project-registration.v1";
|