@teamscala/service-boot 0.23.292 → 0.23.293
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/package.json +10 -2
- package/src/asset-proxy.test.ts +32 -0
- package/src/asset-proxy.ts +1 -1
- package/src/attach-transfers-upload.d.ts +1 -1
- package/src/attach-transfers-upload.test.ts +25 -0
- package/src/attach-transfers-upload.ts +2 -2
- package/src/fn-routers/cache-admin-router.d.ts +2 -2
- package/src/fn-routers/proposal-send-router.d.ts +2 -2
- package/src/fn-routers/public-agreement-router.d.ts +40 -32
- package/src/fn-routers/public-gpt-session-router.d.ts +20 -2
- package/src/fn-routers/public-gpt-session-router.ts +1 -1
- package/src/fn-routers/sign-in-flow-router.d.ts +41 -9
- package/src/fn-routers/task-sync-projection.d.ts +120 -0
- package/src/fn-routers/task-sync-projection.test.ts +123 -0
- package/src/fn-routers/task-sync-projection.ts +201 -0
- package/src/fn-routers/task-sync-router.d.ts +1 -70
- package/src/fn-routers/task-sync-router.test.ts +41 -3
- package/src/fn-routers/task-sync-router.ts +17 -136
- package/src/pg-boss-handler-registry.d.ts +1 -1
- package/src/register-registry-page-route/graph-cache.d.ts +2 -2
- package/src/resource-not-found.test.ts +47 -0
- package/src/resource-not-found.ts +1 -1
package/package.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
},
|
|
7
7
|
"name": "@teamscala/service-boot",
|
|
8
8
|
"type": "module",
|
|
9
|
-
"version": "0.23.
|
|
9
|
+
"version": "0.23.293",
|
|
10
10
|
"sideEffects": false,
|
|
11
11
|
"files": [
|
|
12
12
|
"src"
|
|
@@ -17,7 +17,6 @@
|
|
|
17
17
|
"@teamscala/agreement-generation": "*",
|
|
18
18
|
"@teamscala/api-resend": "*",
|
|
19
19
|
"@teamscala/auth": "*",
|
|
20
|
-
"@teamscala/aws": "*",
|
|
21
20
|
"@teamscala/cache": "*",
|
|
22
21
|
"@teamscala/channel": "*",
|
|
23
22
|
"@teamscala/chat-completion": "*",
|
|
@@ -33,6 +32,7 @@
|
|
|
33
32
|
"@teamscala/generic-registry": "*",
|
|
34
33
|
"@teamscala/http": "*",
|
|
35
34
|
"@teamscala/logger": "*",
|
|
35
|
+
"@teamscala/object-storage": "*",
|
|
36
36
|
"@teamscala/orpc": "*",
|
|
37
37
|
"@teamscala/os": "*",
|
|
38
38
|
"@teamscala/realtime-utils": "*",
|
|
@@ -393,6 +393,14 @@
|
|
|
393
393
|
"types": "./src/fn-routers/task-sync-dispatch.d.ts",
|
|
394
394
|
"default": "./src/fn-routers/task-sync-dispatch.ts"
|
|
395
395
|
},
|
|
396
|
+
"./fn-routers/task-sync-projection": {
|
|
397
|
+
"types": "./src/fn-routers/task-sync-projection.d.ts",
|
|
398
|
+
"default": "./src/fn-routers/task-sync-projection.ts"
|
|
399
|
+
},
|
|
400
|
+
"./fn-routers/task-sync-projection.ts": {
|
|
401
|
+
"types": "./src/fn-routers/task-sync-projection.d.ts",
|
|
402
|
+
"default": "./src/fn-routers/task-sync-projection.ts"
|
|
403
|
+
},
|
|
396
404
|
"./fn-routers/task-sync-router": {
|
|
397
405
|
"types": "./src/fn-routers/task-sync-router.d.ts",
|
|
398
406
|
"default": "./src/fn-routers/task-sync-router.ts"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Co-located tests for handleAssetProxy's early-return paths: a non-asset path
|
|
7
|
+
* 404s, and an asset path with no resolvable S3 client 502s (getClient returns
|
|
8
|
+
* null when creds/config are absent — the documented "no client" contract).
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, it } from "bun:test";
|
|
11
|
+
import { handleAssetProxy } from "./asset-proxy.ts";
|
|
12
|
+
|
|
13
|
+
describe("handleAssetProxy", () => {
|
|
14
|
+
it("404s for a path outside /api/assets/", async () => {
|
|
15
|
+
const res = await handleAssetProxy(new Request("https://host.example/elsewhere"));
|
|
16
|
+
expect(res.status).toBe(404);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("404s for an empty asset key", async () => {
|
|
20
|
+
const res = await handleAssetProxy(new Request("https://host.example/api/assets/"));
|
|
21
|
+
expect(res.status).toBe(404);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("reaches the S3 client for a valid asset key (no early 404)", async () => {
|
|
25
|
+
// A valid key passes the path + key guards and proceeds to getClient.
|
|
26
|
+
// With no registry/env in the test context getClient cannot resolve and
|
|
27
|
+
// rejects — proving the handler did NOT short-circuit on the path/key.
|
|
28
|
+
await expect(
|
|
29
|
+
handleAssetProxy(new Request("https://host.example/api/assets/logo.png")),
|
|
30
|
+
).rejects.toThrow();
|
|
31
|
+
});
|
|
32
|
+
});
|
package/src/asset-proxy.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* Returns 404 for missing keys, 502 for S3 failures. Never redirects.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { getClient } from "@teamscala/
|
|
12
|
+
import { getClient } from "@teamscala/object-storage/create/client/get-client";
|
|
13
13
|
import {
|
|
14
14
|
buildEtag,
|
|
15
15
|
negotiateFormat,
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Authenticated asset upload — POST /api/transfers/upload. Restores the
|
|
7
7
|
* handler deleted with @teamscala/server-files (commit ca5643ef); avatar/logo
|
|
8
8
|
* upload had 404'd on every service since. Writes to S3 via
|
|
9
|
-
* @teamscala/
|
|
9
|
+
* @teamscala/object-storage/create/write-ops and returns the STORED-form URL
|
|
10
10
|
* (`/api/files/<key>`) the client persists into avatar_url/logo_url — the
|
|
11
11
|
* display path rewrites it to the public `/api/assets/<encoded-key>` proxy
|
|
12
12
|
* via `toAssetPath` (@teamscala/formatters/asset-url). Self-authenticates
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Co-located test for attachTransfersUpload: it registers the POST
|
|
7
|
+
* /api/transfers/upload route on the host app. Hono is a type-only import
|
|
8
|
+
* (the real app is built by the runtime), so the host interface is mocked
|
|
9
|
+
* here to exercise registration without the Hono dependency.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, it } from "bun:test";
|
|
12
|
+
import { attachTransfersUpload } from "./attach-transfers-upload.ts";
|
|
13
|
+
|
|
14
|
+
describe("attachTransfersUpload", () => {
|
|
15
|
+
it("registers POST /api/transfers/upload on the host app", async () => {
|
|
16
|
+
const registered: string[] = [];
|
|
17
|
+
const mockApp = {
|
|
18
|
+
post(path: string, _handler: unknown) {
|
|
19
|
+
registered.push(path);
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
await attachTransfersUpload(mockApp as never);
|
|
23
|
+
expect(registered).toContain("/api/transfers/upload");
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Authenticated asset upload — POST /api/transfers/upload. Restores the
|
|
7
7
|
* handler deleted with @teamscala/server-files (commit ca5643ef); avatar/logo
|
|
8
8
|
* upload had 404'd on every service since. Writes to S3 via
|
|
9
|
-
* @teamscala/
|
|
9
|
+
* @teamscala/object-storage/create/write-ops and returns the STORED-form URL
|
|
10
10
|
* (`/api/files/<key>`) the client persists into avatar_url/logo_url — the
|
|
11
11
|
* display path rewrites it to the public `/api/assets/<encoded-key>` proxy
|
|
12
12
|
* via `toAssetPath` (@teamscala/formatters/asset-url). Self-authenticates
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
import type { Hono } from "hono";
|
|
27
27
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
28
|
-
import { upload } from "@teamscala/
|
|
28
|
+
import { upload } from "@teamscala/object-storage/create/write-ops";
|
|
29
29
|
import { getSession } from "@teamscala/auth/get-session";
|
|
30
30
|
import { buildErrorReport } from "@teamscala/os/errors/error-report";
|
|
31
31
|
|
|
@@ -50,8 +50,8 @@ export declare const cacheAdminRouter: {
|
|
|
50
50
|
}>, v.ObjectSchema<{
|
|
51
51
|
readonly name: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
|
|
52
52
|
}, undefined>, import("@orpc/contract").Schema<{
|
|
53
|
-
flushed:
|
|
53
|
+
flushed: number;
|
|
54
54
|
}, {
|
|
55
|
-
flushed:
|
|
55
|
+
flushed: number;
|
|
56
56
|
}>, Record<never, never>, Record<never, never>>;
|
|
57
57
|
};
|
|
@@ -31,7 +31,7 @@ export declare const proposalSendRouter: {
|
|
|
31
31
|
readonly baseUrl: v.StringSchema<undefined>;
|
|
32
32
|
}, undefined>, import("@orpc/contract").Schema<{
|
|
33
33
|
ok: false;
|
|
34
|
-
error:
|
|
34
|
+
error: string;
|
|
35
35
|
sentAt?: undefined;
|
|
36
36
|
url?: undefined;
|
|
37
37
|
email?: undefined;
|
|
@@ -43,7 +43,7 @@ export declare const proposalSendRouter: {
|
|
|
43
43
|
email: string;
|
|
44
44
|
}, {
|
|
45
45
|
ok: false;
|
|
46
|
-
error:
|
|
46
|
+
error: string;
|
|
47
47
|
sentAt?: undefined;
|
|
48
48
|
url?: undefined;
|
|
49
49
|
email?: undefined;
|
|
@@ -114,12 +114,12 @@ export declare const publicAgreementRouter: {
|
|
|
114
114
|
matched: boolean;
|
|
115
115
|
multiParty: boolean;
|
|
116
116
|
name: any;
|
|
117
|
-
role:
|
|
118
|
-
signerName:
|
|
119
|
-
partySignedAt:
|
|
120
|
-
allSigned:
|
|
121
|
-
parties:
|
|
122
|
-
accessToken:
|
|
117
|
+
role: string;
|
|
118
|
+
signerName: string;
|
|
119
|
+
partySignedAt: string | null;
|
|
120
|
+
allSigned: boolean;
|
|
121
|
+
parties: import("@teamscala/document-signing/agreement-parties").PublicAgreementParty[];
|
|
122
|
+
accessToken: string | null;
|
|
123
123
|
signedAt?: undefined;
|
|
124
124
|
} | {
|
|
125
125
|
multiParty?: undefined;
|
|
@@ -131,7 +131,7 @@ export declare const publicAgreementRouter: {
|
|
|
131
131
|
name: any;
|
|
132
132
|
signerName: any;
|
|
133
133
|
signedAt: any;
|
|
134
|
-
accessToken:
|
|
134
|
+
accessToken: string | null;
|
|
135
135
|
}, {
|
|
136
136
|
matched: false;
|
|
137
137
|
multiParty?: undefined;
|
|
@@ -147,12 +147,12 @@ export declare const publicAgreementRouter: {
|
|
|
147
147
|
matched: boolean;
|
|
148
148
|
multiParty: boolean;
|
|
149
149
|
name: any;
|
|
150
|
-
role:
|
|
151
|
-
signerName:
|
|
152
|
-
partySignedAt:
|
|
153
|
-
allSigned:
|
|
154
|
-
parties:
|
|
155
|
-
accessToken:
|
|
150
|
+
role: string;
|
|
151
|
+
signerName: string;
|
|
152
|
+
partySignedAt: string | null;
|
|
153
|
+
allSigned: boolean;
|
|
154
|
+
parties: import("@teamscala/document-signing/agreement-parties").PublicAgreementParty[];
|
|
155
|
+
accessToken: string | null;
|
|
156
156
|
signedAt?: undefined;
|
|
157
157
|
} | {
|
|
158
158
|
multiParty?: undefined;
|
|
@@ -164,7 +164,7 @@ export declare const publicAgreementRouter: {
|
|
|
164
164
|
name: any;
|
|
165
165
|
signerName: any;
|
|
166
166
|
signedAt: any;
|
|
167
|
-
accessToken:
|
|
167
|
+
accessToken: string | null;
|
|
168
168
|
}>, Record<never, never>, Record<never, never>>;
|
|
169
169
|
sign: import("@orpc/server").DecoratedProcedure<Context & Record<never, never>, Context, v.ObjectSchema<{
|
|
170
170
|
readonly agreementId: v.StringSchema<undefined>;
|
|
@@ -182,10 +182,10 @@ export declare const publicAgreementRouter: {
|
|
|
182
182
|
payUrl?: undefined;
|
|
183
183
|
} | {
|
|
184
184
|
ok: true;
|
|
185
|
-
role:
|
|
186
|
-
signedAt:
|
|
187
|
-
allSigned:
|
|
188
|
-
parties:
|
|
185
|
+
role: string;
|
|
186
|
+
signedAt: string;
|
|
187
|
+
allSigned: boolean;
|
|
188
|
+
parties: import("@teamscala/document-signing/agreement-parties").PublicAgreementParty[];
|
|
189
189
|
payUrl: string | null;
|
|
190
190
|
} | {
|
|
191
191
|
error?: undefined;
|
|
@@ -210,10 +210,10 @@ export declare const publicAgreementRouter: {
|
|
|
210
210
|
payUrl?: undefined;
|
|
211
211
|
} | {
|
|
212
212
|
ok: true;
|
|
213
|
-
role:
|
|
214
|
-
signedAt:
|
|
215
|
-
allSigned:
|
|
216
|
-
parties:
|
|
213
|
+
role: string;
|
|
214
|
+
signedAt: string;
|
|
215
|
+
allSigned: boolean;
|
|
216
|
+
parties: import("@teamscala/document-signing/agreement-parties").PublicAgreementParty[];
|
|
217
217
|
payUrl: string | null;
|
|
218
218
|
} | {
|
|
219
219
|
error?: undefined;
|
|
@@ -248,14 +248,18 @@ export declare const publicAgreementRouter: {
|
|
|
248
248
|
valid?: undefined;
|
|
249
249
|
} | {
|
|
250
250
|
ok: true;
|
|
251
|
-
envelopeId:
|
|
252
|
-
documentName:
|
|
253
|
-
complete:
|
|
254
|
-
signedAt:
|
|
251
|
+
envelopeId: string;
|
|
252
|
+
documentName: string;
|
|
253
|
+
complete: boolean;
|
|
254
|
+
signedAt: string | null;
|
|
255
255
|
sealedHash: string | null;
|
|
256
256
|
recomputedHash: string | null;
|
|
257
257
|
valid: boolean;
|
|
258
|
-
parties:
|
|
258
|
+
parties: {
|
|
259
|
+
role: string;
|
|
260
|
+
name: string;
|
|
261
|
+
signedAt: string | null;
|
|
262
|
+
}[];
|
|
259
263
|
}, {
|
|
260
264
|
parties?: undefined;
|
|
261
265
|
signedAt?: undefined;
|
|
@@ -268,14 +272,18 @@ export declare const publicAgreementRouter: {
|
|
|
268
272
|
valid?: undefined;
|
|
269
273
|
} | {
|
|
270
274
|
ok: true;
|
|
271
|
-
envelopeId:
|
|
272
|
-
documentName:
|
|
273
|
-
complete:
|
|
274
|
-
signedAt:
|
|
275
|
+
envelopeId: string;
|
|
276
|
+
documentName: string;
|
|
277
|
+
complete: boolean;
|
|
278
|
+
signedAt: string | null;
|
|
275
279
|
sealedHash: string | null;
|
|
276
280
|
recomputedHash: string | null;
|
|
277
281
|
valid: boolean;
|
|
278
|
-
parties:
|
|
282
|
+
parties: {
|
|
283
|
+
role: string;
|
|
284
|
+
name: string;
|
|
285
|
+
signedAt: string | null;
|
|
286
|
+
}[];
|
|
279
287
|
}>, Record<never, never>, Record<never, never>>;
|
|
280
288
|
};
|
|
281
289
|
export {};
|
|
@@ -21,7 +21,7 @@ export declare const publicGptSessionRouter: {
|
|
|
21
21
|
readonly orgId: v.StringSchema<undefined>;
|
|
22
22
|
readonly sourceUrl: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
|
|
23
23
|
}, undefined>, import("@orpc/contract").Schema<{
|
|
24
|
-
conversationId:
|
|
24
|
+
conversationId: string;
|
|
25
25
|
contactName: any;
|
|
26
26
|
contactPhone: any;
|
|
27
27
|
messages: any;
|
|
@@ -29,15 +29,33 @@ export declare const publicGptSessionRouter: {
|
|
|
29
29
|
inputTokens: number;
|
|
30
30
|
outputTokens: number;
|
|
31
31
|
};
|
|
32
|
-
}
|
|
32
|
+
} | {
|
|
33
33
|
conversationId: any;
|
|
34
34
|
contactName: any;
|
|
35
35
|
contactPhone: any;
|
|
36
|
+
messages: never[];
|
|
37
|
+
tokenTotals: {
|
|
38
|
+
inputTokens: number;
|
|
39
|
+
outputTokens: number;
|
|
40
|
+
};
|
|
41
|
+
}, {
|
|
42
|
+
conversationId: string;
|
|
43
|
+
contactName: any;
|
|
44
|
+
contactPhone: any;
|
|
36
45
|
messages: any;
|
|
37
46
|
tokenTotals: {
|
|
38
47
|
inputTokens: number;
|
|
39
48
|
outputTokens: number;
|
|
40
49
|
};
|
|
50
|
+
} | {
|
|
51
|
+
conversationId: any;
|
|
52
|
+
contactName: any;
|
|
53
|
+
contactPhone: any;
|
|
54
|
+
messages: never[];
|
|
55
|
+
tokenTotals: {
|
|
56
|
+
inputTokens: number;
|
|
57
|
+
outputTokens: number;
|
|
58
|
+
};
|
|
41
59
|
}>, Record<never, never>, Record<never, never>>;
|
|
42
60
|
load: import("@orpc/server").DecoratedProcedure<Context & Record<never, never>, Context, v.ObjectSchema<{
|
|
43
61
|
readonly conversationId: v.StringSchema<undefined>;
|
|
@@ -11,7 +11,7 @@ import { os } from "@orpc/server";
|
|
|
11
11
|
import * as v from "valibot";
|
|
12
12
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
13
13
|
import { queryRows } from "@teamscala/db/query-rows";
|
|
14
|
-
import { getClient } from "@teamscala/
|
|
14
|
+
import { getClient } from "@teamscala/object-storage/create/client/get-client";
|
|
15
15
|
import { persistChatMessages } from "./persist-chat-messages.ts";
|
|
16
16
|
|
|
17
17
|
interface Context {
|
|
@@ -21,28 +21,60 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import type { Context } from "./auth-router.ts";
|
|
23
23
|
export declare const signInFlowRouter: {
|
|
24
|
-
getSignInFlowConfig: import("@orpc/server").DecoratedProcedure<Context & Record<never, never>, Context, import("@orpc/contract").Schema<unknown, unknown>, import("@orpc/contract").Schema<
|
|
24
|
+
getSignInFlowConfig: import("@orpc/server").DecoratedProcedure<Context & Record<never, never>, Context, import("@orpc/contract").Schema<unknown, unknown>, import("@orpc/contract").Schema<{
|
|
25
|
+
signInPath: string;
|
|
26
|
+
portalPathPrefix: string;
|
|
27
|
+
redirectQueryParam: string;
|
|
28
|
+
sessionCheckEndpoint: string;
|
|
29
|
+
portalsEndpoint: string;
|
|
30
|
+
clearStaleCookiesEndpoint: string;
|
|
31
|
+
portalRedirects: {
|
|
32
|
+
orgDashboardPathTemplate: string;
|
|
33
|
+
individualDashboardPathTemplate: string;
|
|
34
|
+
};
|
|
35
|
+
}, {
|
|
36
|
+
signInPath: string;
|
|
37
|
+
portalPathPrefix: string;
|
|
38
|
+
redirectQueryParam: string;
|
|
39
|
+
sessionCheckEndpoint: string;
|
|
40
|
+
portalsEndpoint: string;
|
|
41
|
+
clearStaleCookiesEndpoint: string;
|
|
42
|
+
portalRedirects: {
|
|
43
|
+
orgDashboardPathTemplate: string;
|
|
44
|
+
individualDashboardPathTemplate: string;
|
|
45
|
+
};
|
|
46
|
+
}>, Record<never, never>, Record<never, never>>;
|
|
25
47
|
checkSession: import("@orpc/server").DecoratedProcedure<Context & Record<never, never>, Context, import("@orpc/contract").Schema<unknown, unknown>, import("@orpc/contract").Schema<{
|
|
26
48
|
user?: undefined;
|
|
27
49
|
redirectUrl?: undefined;
|
|
28
50
|
portalCount?: undefined;
|
|
29
51
|
} | {
|
|
30
|
-
user:
|
|
31
|
-
|
|
52
|
+
user: {
|
|
53
|
+
id: string;
|
|
54
|
+
email: string;
|
|
55
|
+
name: string | null;
|
|
56
|
+
role: string | null;
|
|
57
|
+
};
|
|
58
|
+
redirectUrl: string;
|
|
32
59
|
portalCount: number;
|
|
33
60
|
}, {
|
|
34
61
|
user?: undefined;
|
|
35
62
|
redirectUrl?: undefined;
|
|
36
63
|
portalCount?: undefined;
|
|
37
64
|
} | {
|
|
38
|
-
user:
|
|
39
|
-
|
|
65
|
+
user: {
|
|
66
|
+
id: string;
|
|
67
|
+
email: string;
|
|
68
|
+
name: string | null;
|
|
69
|
+
role: string | null;
|
|
70
|
+
};
|
|
71
|
+
redirectUrl: string;
|
|
40
72
|
portalCount: number;
|
|
41
73
|
}>, Record<never, never>, Record<never, never>>;
|
|
42
74
|
listPortals: import("@orpc/server").DecoratedProcedure<Context & Record<never, never>, Context, import("@orpc/contract").Schema<unknown, unknown>, import("@orpc/contract").Schema<{
|
|
43
75
|
portals: never[];
|
|
44
76
|
userOrgId: null;
|
|
45
|
-
userName:
|
|
77
|
+
userName: string | null;
|
|
46
78
|
} | {
|
|
47
79
|
portals: {
|
|
48
80
|
id: unknown;
|
|
@@ -54,11 +86,11 @@ export declare const signInFlowRouter: {
|
|
|
54
86
|
portal_type: {} | null;
|
|
55
87
|
}[];
|
|
56
88
|
userOrgId: string;
|
|
57
|
-
userName:
|
|
89
|
+
userName: string | null;
|
|
58
90
|
}, {
|
|
59
91
|
portals: never[];
|
|
60
92
|
userOrgId: null;
|
|
61
|
-
userName:
|
|
93
|
+
userName: string | null;
|
|
62
94
|
} | {
|
|
63
95
|
portals: {
|
|
64
96
|
id: unknown;
|
|
@@ -70,6 +102,6 @@ export declare const signInFlowRouter: {
|
|
|
70
102
|
portal_type: {} | null;
|
|
71
103
|
}[];
|
|
72
104
|
userOrgId: string;
|
|
73
|
-
userName:
|
|
105
|
+
userName: string | null;
|
|
74
106
|
}>, Record<never, never>, Record<never, never>>;
|
|
75
107
|
};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* The task-sync PROJECTION: how a CLI's task list maps onto `work_items` rows,
|
|
7
|
+
* and what reconciling one against the other should do.
|
|
8
|
+
*
|
|
9
|
+
* Split out of `task-sync-router.ts`, which had grown past the 1000-line budget
|
|
10
|
+
* and so could not be committed by anyone touching it. The seam is real rather
|
|
11
|
+
* than arbitrary: everything here is PURE and decides WHAT should change, while
|
|
12
|
+
* the router owns the ORPC surface and performs the writes. That is exactly why
|
|
13
|
+
* this half is directly testable with no database.
|
|
14
|
+
*
|
|
15
|
+
* CAPABILITY HARVEST (capability-harvest-on-split): APP-LOCAL, not a capability
|
|
16
|
+
* candidate. Checked against the 14 platform singletons and the package catalog
|
|
17
|
+
* — nothing generic to fold into, and the doctrine test fails: no unrelated app
|
|
18
|
+
* could install this unmodified, because every export is bound to the
|
|
19
|
+
* `work_items` schema and task-sync's own contract (STATUS_TO_WORK_ITEM encodes
|
|
20
|
+
* the work_items status enum, subtaskKey is this projection's key convention,
|
|
21
|
+
* planReconcile diffs against work_items subtask rows).
|
|
22
|
+
*
|
|
23
|
+
* The near-miss is worth naming so the next reader does not re-derive it: the
|
|
24
|
+
* SHAPE — diff a desired list against existing rows into create/update/delete —
|
|
25
|
+
* is generic, and `document-signing` is precedent for lifting a pure domain half
|
|
26
|
+
* out of service-boot into a package. It stays here because it has exactly one
|
|
27
|
+
* consumer; extracting it now would be speculative packaging, which
|
|
28
|
+
* platform-capabilities treats as drift. Revisit if a SECOND surface needs
|
|
29
|
+
* list-against-rows reconciliation — then the generic diff core (not the
|
|
30
|
+
* work_items-specific mapping) is what earns a package.
|
|
31
|
+
*/
|
|
32
|
+
import type { TaskListItem, WorkItem } from "./task-sync-router.ts";
|
|
33
|
+
export declare const STATUS_TO_WORK_ITEM: Record<TaskListItem["status"], string>;
|
|
34
|
+
export declare function normalizeStatus(s: string | null): TaskListItem["status"];
|
|
35
|
+
/**
|
|
36
|
+
* Decide the status to WRITE for an existing row, given what the CLI list says.
|
|
37
|
+
*
|
|
38
|
+
* The CLI task list has no `blocked` state, so a blocked row round-trips as
|
|
39
|
+
* `pending` (via `normalizeStatus`, which collapses unknown/internal statuses)
|
|
40
|
+
* and then maps back to `todo` — silently destroying the block. That is not a
|
|
41
|
+
* cosmetic drift: the stop gate reads these rows, so un-blocking one re-blocks
|
|
42
|
+
* the LANE from stopping, and the lane's only apparent fix is to touch the list
|
|
43
|
+
* again, which destroys it again.
|
|
44
|
+
*
|
|
45
|
+
* A `todo` arriving from the CLI therefore carries NO information: it is what
|
|
46
|
+
* the projection emits for every status it cannot represent, not an assertion
|
|
47
|
+
* that the work became un-blocked. `in-progress` and `done` are different —
|
|
48
|
+
* those are real transitions the CLI CAN express, so they win normally.
|
|
49
|
+
*
|
|
50
|
+
* Measured 2026-07-30: a lane set a row to `blocked` with a named blocker, the
|
|
51
|
+
* next TaskUpdate reset it to `todo`, and the stop gate reported the lane
|
|
52
|
+
* unfinished again — three turns of ping-pong before the cause was found.
|
|
53
|
+
*/
|
|
54
|
+
export declare function resolveProjectedStatus(currentStatus: string | null | undefined, incomingStatus: string): string;
|
|
55
|
+
export declare function subtaskKey(item: {
|
|
56
|
+
mark?: string;
|
|
57
|
+
content: string;
|
|
58
|
+
title: string;
|
|
59
|
+
}): string;
|
|
60
|
+
/**
|
|
61
|
+
* Pure decision for a TaskUpdate/TaskDelete: which existing subtask does this
|
|
62
|
+
* event address, and what should happen when none does. Extracted for the same
|
|
63
|
+
* reason planReconcile was — the handler should only EXECUTE, so the matching
|
|
64
|
+
* rule is unit-testable.
|
|
65
|
+
*
|
|
66
|
+
* Three resolutions:
|
|
67
|
+
* - `update` — matched by mark, or (mark absent/unstamped) by exact title.
|
|
68
|
+
* - `heal` — nothing matched but the event carries a subject, so the row can
|
|
69
|
+
* be reconstructed. Covers a TaskCreate that silently produced no
|
|
70
|
+
* subtask, which previously orphaned the task forever.
|
|
71
|
+
* - `discard` — nothing matched and there is no subject to rebuild a title from.
|
|
72
|
+
*
|
|
73
|
+
* Measured 2026-07-28: marks "1" and "3" were stamped for one lane while mark
|
|
74
|
+
* "2" was never written at all, and every later update for it was dropped as
|
|
75
|
+
* `no-matching-subtask` — 26 of 30 tasksync discards in six hours, across seven
|
|
76
|
+
* distinct lane sessions, were that one reason.
|
|
77
|
+
*/
|
|
78
|
+
export declare function resolveTaskUpdateTarget(existing: Array<{
|
|
79
|
+
id: string;
|
|
80
|
+
title: string;
|
|
81
|
+
kind_data?: Record<string, unknown> | null;
|
|
82
|
+
}>, evt: {
|
|
83
|
+
taskId: string;
|
|
84
|
+
subject?: string;
|
|
85
|
+
}): {
|
|
86
|
+
op: "update";
|
|
87
|
+
id: string;
|
|
88
|
+
} | {
|
|
89
|
+
op: "heal";
|
|
90
|
+
title: string;
|
|
91
|
+
} | {
|
|
92
|
+
op: "discard";
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* Pure reconciliation plan: given the lane's EXISTING subtasks and its INCOMING
|
|
96
|
+
* (current) task list, compute the create/update/delete ops that make the
|
|
97
|
+
* subtask set match the list — WITHOUT touching prisma. Extracted so the
|
|
98
|
+
* matching/status/positioning logic is unit-testable (the handler only executes
|
|
99
|
+
* the plan). Matching is by stable mark when the CLI provides one, else by
|
|
100
|
+
* content. Position follows incoming index, so a reorder is an update.
|
|
101
|
+
*/
|
|
102
|
+
export interface ReconcilePlan {
|
|
103
|
+
toCreate: Array<{
|
|
104
|
+
content: string;
|
|
105
|
+
status: string;
|
|
106
|
+
completed: boolean;
|
|
107
|
+
position: string;
|
|
108
|
+
kind_data: Record<string, unknown>;
|
|
109
|
+
}>;
|
|
110
|
+
toUpdate: Array<{
|
|
111
|
+
id: string;
|
|
112
|
+
content: string;
|
|
113
|
+
status: string;
|
|
114
|
+
completed: boolean;
|
|
115
|
+
position: string;
|
|
116
|
+
kind_data: Record<string, unknown>;
|
|
117
|
+
}>;
|
|
118
|
+
toDelete: string[];
|
|
119
|
+
}
|
|
120
|
+
export declare function planReconcile(existing: WorkItem[], incoming: TaskListItem[], source?: string): ReconcilePlan;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Co-located tests for the task-sync PROJECTION pure helpers. The module was
|
|
7
|
+
* split out of task-sync-router.ts precisely so this half — the WHAT-changes
|
|
8
|
+
* decisions — is unit-testable with no database (the router only EXECUTES the
|
|
9
|
+
* plan). Covers status mapping, the blocked-row preservation rule, key
|
|
10
|
+
* resolution, update-target resolution + full reconciliation planning.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, expect, it } from "bun:test";
|
|
13
|
+
import {
|
|
14
|
+
STATUS_TO_WORK_ITEM,
|
|
15
|
+
normalizeStatus,
|
|
16
|
+
planReconcile,
|
|
17
|
+
resolveProjectedStatus,
|
|
18
|
+
resolveTaskUpdateTarget,
|
|
19
|
+
subtaskKey,
|
|
20
|
+
type ReconcilePlan,
|
|
21
|
+
} from "./task-sync-projection.ts";
|
|
22
|
+
import type { TaskListItem } from "./task-sync-router.ts";
|
|
23
|
+
|
|
24
|
+
describe("STATUS_TO_WORK_ITEM", () => {
|
|
25
|
+
it("maps the three CLI statuses to work_items status enum values", () => {
|
|
26
|
+
expect(STATUS_TO_WORK_ITEM.pending).toBe("todo");
|
|
27
|
+
expect(STATUS_TO_WORK_ITEM.in_progress).toBe("in-progress");
|
|
28
|
+
expect(STATUS_TO_WORK_ITEM.completed).toBe("done");
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("normalizeStatus", () => {
|
|
33
|
+
it("maps work_items statuses back to the normalized CLI form", () => {
|
|
34
|
+
expect(normalizeStatus("todo")).toBe("pending");
|
|
35
|
+
expect(normalizeStatus("in-progress")).toBe("in_progress");
|
|
36
|
+
expect(normalizeStatus("done")).toBe("completed");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("collapses unknown / null / internal statuses to pending", () => {
|
|
40
|
+
expect(normalizeStatus(null)).toBe("pending");
|
|
41
|
+
expect(normalizeStatus("blocked")).toBe("pending");
|
|
42
|
+
expect(normalizeStatus("some-internal-state")).toBe("pending");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("resolveProjectedStatus", () => {
|
|
47
|
+
it("preserves a blocked row when the CLI list can only express it as todo", () => {
|
|
48
|
+
expect(resolveProjectedStatus("blocked", "todo")).toBe("blocked");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("lets real CLI transitions (in-progress, done) win normally", () => {
|
|
52
|
+
expect(resolveProjectedStatus("blocked", "in-progress")).toBe("in-progress");
|
|
53
|
+
expect(resolveProjectedStatus("todo", "done")).toBe("done");
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("subtaskKey", () => {
|
|
58
|
+
it("prefers mark, then content, then title", () => {
|
|
59
|
+
expect(subtaskKey({ mark: "1", content: "c", title: "t" })).toBe("1");
|
|
60
|
+
expect(subtaskKey({ content: "c", title: "t" })).toBe("c");
|
|
61
|
+
expect(subtaskKey({ content: "", title: "t" })).toBe("t");
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("resolveTaskUpdateTarget", () => {
|
|
66
|
+
const existing = [
|
|
67
|
+
{ id: "a", title: "Alpha", kind_data: { task_sync: { mark: "1" } } },
|
|
68
|
+
{ id: "b", title: "Beta", kind_data: null },
|
|
69
|
+
];
|
|
70
|
+
it("updates by stamped mark", () => {
|
|
71
|
+
expect(resolveTaskUpdateTarget(existing, { taskId: "1", subject: "anything" })).toEqual({
|
|
72
|
+
op: "update",
|
|
73
|
+
id: "a",
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
it("updates by exact title when no mark matches", () => {
|
|
77
|
+
expect(resolveTaskUpdateTarget(existing, { taskId: "9", subject: "Beta" })).toEqual({
|
|
78
|
+
op: "update",
|
|
79
|
+
id: "b",
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
it("heals when nothing matches but a subject can rebuild the row", () => {
|
|
83
|
+
expect(resolveTaskUpdateTarget(existing, { taskId: "9", subject: "Gamma" })).toEqual({
|
|
84
|
+
op: "heal",
|
|
85
|
+
title: "Gamma",
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
it("discards when nothing matches and there is no subject", () => {
|
|
89
|
+
expect(resolveTaskUpdateTarget(existing, { taskId: "9" })).toEqual({ op: "discard" });
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("planReconcile", () => {
|
|
94
|
+
it("creates new items, updates changed ones, deletes unseen ones", () => {
|
|
95
|
+
const existing = [
|
|
96
|
+
{ id: "1", kind: "subtask", parent_id: null, organisation_id: "org", title: "Keep", status: "todo", completed: false, position: "0", kind_data: {} },
|
|
97
|
+
{ id: "2", kind: "subtask", parent_id: null, organisation_id: "org", title: "Gone", status: "todo", completed: false, position: "1", kind_data: {} },
|
|
98
|
+
];
|
|
99
|
+
const incoming: TaskListItem[] = [
|
|
100
|
+
{ content: "Keep", status: "completed", mark: undefined },
|
|
101
|
+
{ content: "New", status: "pending", mark: undefined },
|
|
102
|
+
];
|
|
103
|
+
const plan: ReconcilePlan = planReconcile(existing, incoming);
|
|
104
|
+
// "Keep" changed (todo→done) → update; "Gone" absent → delete; "New" → create.
|
|
105
|
+
expect(plan.toUpdate.map((u) => u.id)).toEqual(["1"]);
|
|
106
|
+
expect(plan.toUpdate[0]?.status).toBe("done");
|
|
107
|
+
expect(plan.toCreate.map((c) => c.content)).toEqual(["New"]);
|
|
108
|
+
expect(plan.toDelete).toEqual(["2"]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("preserves a blocked existing row against an incoming todo", () => {
|
|
112
|
+
const existing = [
|
|
113
|
+
{ id: "1", kind: "subtask", parent_id: null, organisation_id: "org", title: "X", status: "blocked", completed: false, position: "5", kind_data: {} },
|
|
114
|
+
];
|
|
115
|
+
// Incoming pending (-> "todo") at a DIFFERENT position so an update fires;
|
|
116
|
+
// without the preservation rule the row would be rewritten to "todo",
|
|
117
|
+
// silently destroying the block.
|
|
118
|
+
const incoming: TaskListItem[] = [{ content: "X", status: "pending", mark: undefined }];
|
|
119
|
+
const plan = planReconcile(existing, incoming, "src");
|
|
120
|
+
expect(plan.toUpdate.length).toBe(1);
|
|
121
|
+
expect(plan.toUpdate[0]?.status).toBe("blocked");
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* The task-sync PROJECTION: how a CLI's task list maps onto `work_items` rows,
|
|
7
|
+
* and what reconciling one against the other should do.
|
|
8
|
+
*
|
|
9
|
+
* Split out of `task-sync-router.ts`, which had grown past the 1000-line budget
|
|
10
|
+
* and so could not be committed by anyone touching it. The seam is real rather
|
|
11
|
+
* than arbitrary: everything here is PURE and decides WHAT should change, while
|
|
12
|
+
* the router owns the ORPC surface and performs the writes. That is exactly why
|
|
13
|
+
* this half is directly testable with no database.
|
|
14
|
+
*
|
|
15
|
+
* CAPABILITY HARVEST (capability-harvest-on-split): APP-LOCAL, not a capability
|
|
16
|
+
* candidate. Checked against the 14 platform singletons and the package catalog
|
|
17
|
+
* — nothing generic to fold into, and the doctrine test fails: no unrelated app
|
|
18
|
+
* could install this unmodified, because every export is bound to the
|
|
19
|
+
* `work_items` schema and task-sync's own contract (STATUS_TO_WORK_ITEM encodes
|
|
20
|
+
* the work_items status enum, subtaskKey is this projection's key convention,
|
|
21
|
+
* planReconcile diffs against work_items subtask rows).
|
|
22
|
+
*
|
|
23
|
+
* The near-miss is worth naming so the next reader does not re-derive it: the
|
|
24
|
+
* SHAPE — diff a desired list against existing rows into create/update/delete —
|
|
25
|
+
* is generic, and `document-signing` is precedent for lifting a pure domain half
|
|
26
|
+
* out of service-boot into a package. It stays here because it has exactly one
|
|
27
|
+
* consumer; extracting it now would be speculative packaging, which
|
|
28
|
+
* platform-capabilities treats as drift. Revisit if a SECOND surface needs
|
|
29
|
+
* list-against-rows reconciliation — then the generic diff core (not the
|
|
30
|
+
* work_items-specific mapping) is what earns a package.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import type { TaskListItem, WorkItem } from "./task-sync-router.ts";
|
|
34
|
+
|
|
35
|
+
// TodoWrite-style status (the normalized form) → work_items.status (DB enum).
|
|
36
|
+
// The DB enum uses a hyphen for the active state ("in-progress"); see
|
|
37
|
+
// COLUMN_ENUMS["work_items.status"] in db-validation.
|
|
38
|
+
export const STATUS_TO_WORK_ITEM: Record<TaskListItem["status"], string> = {
|
|
39
|
+
pending: "todo",
|
|
40
|
+
in_progress: "in-progress",
|
|
41
|
+
completed: "done",
|
|
42
|
+
};
|
|
43
|
+
// work_items.status → normalized. Unknown/internal statuses collapse to pending.
|
|
44
|
+
const WORK_ITEM_TO_STATUS: Record<string, TaskListItem["status"]> = {
|
|
45
|
+
todo: "pending",
|
|
46
|
+
"in-progress": "in_progress",
|
|
47
|
+
active: "in_progress",
|
|
48
|
+
done: "completed",
|
|
49
|
+
completed: "completed",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export function normalizeStatus(s: string | null): TaskListItem["status"] {
|
|
53
|
+
return (s && WORK_ITEM_TO_STATUS[s]) || "pending";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Decide the status to WRITE for an existing row, given what the CLI list says.
|
|
58
|
+
*
|
|
59
|
+
* The CLI task list has no `blocked` state, so a blocked row round-trips as
|
|
60
|
+
* `pending` (via `normalizeStatus`, which collapses unknown/internal statuses)
|
|
61
|
+
* and then maps back to `todo` — silently destroying the block. That is not a
|
|
62
|
+
* cosmetic drift: the stop gate reads these rows, so un-blocking one re-blocks
|
|
63
|
+
* the LANE from stopping, and the lane's only apparent fix is to touch the list
|
|
64
|
+
* again, which destroys it again.
|
|
65
|
+
*
|
|
66
|
+
* A `todo` arriving from the CLI therefore carries NO information: it is what
|
|
67
|
+
* the projection emits for every status it cannot represent, not an assertion
|
|
68
|
+
* that the work became un-blocked. `in-progress` and `done` are different —
|
|
69
|
+
* those are real transitions the CLI CAN express, so they win normally.
|
|
70
|
+
*
|
|
71
|
+
* Measured 2026-07-30: a lane set a row to `blocked` with a named blocker, the
|
|
72
|
+
* next TaskUpdate reset it to `todo`, and the stop gate reported the lane
|
|
73
|
+
* unfinished again — three turns of ping-pong before the cause was found.
|
|
74
|
+
*/
|
|
75
|
+
export function resolveProjectedStatus(
|
|
76
|
+
currentStatus: string | null | undefined,
|
|
77
|
+
incomingStatus: string,
|
|
78
|
+
): string {
|
|
79
|
+
if (currentStatus === "blocked" && incomingStatus === "todo") return "blocked";
|
|
80
|
+
return incomingStatus;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function subtaskKey(item: { mark?: string; content: string; title: string }): string {
|
|
84
|
+
return item.mark || item.content || item.title;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Pure decision for a TaskUpdate/TaskDelete: which existing subtask does this
|
|
89
|
+
* event address, and what should happen when none does. Extracted for the same
|
|
90
|
+
* reason planReconcile was — the handler should only EXECUTE, so the matching
|
|
91
|
+
* rule is unit-testable.
|
|
92
|
+
*
|
|
93
|
+
* Three resolutions:
|
|
94
|
+
* - `update` — matched by mark, or (mark absent/unstamped) by exact title.
|
|
95
|
+
* - `heal` — nothing matched but the event carries a subject, so the row can
|
|
96
|
+
* be reconstructed. Covers a TaskCreate that silently produced no
|
|
97
|
+
* subtask, which previously orphaned the task forever.
|
|
98
|
+
* - `discard` — nothing matched and there is no subject to rebuild a title from.
|
|
99
|
+
*
|
|
100
|
+
* Measured 2026-07-28: marks "1" and "3" were stamped for one lane while mark
|
|
101
|
+
* "2" was never written at all, and every later update for it was dropped as
|
|
102
|
+
* `no-matching-subtask` — 26 of 30 tasksync discards in six hours, across seven
|
|
103
|
+
* distinct lane sessions, were that one reason.
|
|
104
|
+
*/
|
|
105
|
+
export function resolveTaskUpdateTarget(
|
|
106
|
+
existing: Array<{ id: string; title: string; kind_data?: Record<string, unknown> | null }>,
|
|
107
|
+
evt: { taskId: string; subject?: string },
|
|
108
|
+
): { op: "update"; id: string } | { op: "heal"; title: string } | { op: "discard" } {
|
|
109
|
+
const byMark = existing.find(
|
|
110
|
+
(s) =>
|
|
111
|
+
evt.taskId &&
|
|
112
|
+
(s.kind_data as { task_sync?: { mark?: string } } | null)?.task_sync?.mark === evt.taskId,
|
|
113
|
+
);
|
|
114
|
+
if (byMark) return { op: "update", id: byMark.id };
|
|
115
|
+
const subject = (evt.subject ?? "").trim();
|
|
116
|
+
if (subject) {
|
|
117
|
+
const byTitle = existing.find((s) => s.title === subject);
|
|
118
|
+
if (byTitle) return { op: "update", id: byTitle.id };
|
|
119
|
+
return { op: "heal", title: subject };
|
|
120
|
+
}
|
|
121
|
+
return { op: "discard" };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Pure reconciliation plan: given the lane's EXISTING subtasks and its INCOMING
|
|
126
|
+
* (current) task list, compute the create/update/delete ops that make the
|
|
127
|
+
* subtask set match the list — WITHOUT touching prisma. Extracted so the
|
|
128
|
+
* matching/status/positioning logic is unit-testable (the handler only executes
|
|
129
|
+
* the plan). Matching is by stable mark when the CLI provides one, else by
|
|
130
|
+
* content. Position follows incoming index, so a reorder is an update.
|
|
131
|
+
*/
|
|
132
|
+
export interface ReconcilePlan {
|
|
133
|
+
toCreate: Array<{
|
|
134
|
+
content: string;
|
|
135
|
+
status: string;
|
|
136
|
+
completed: boolean;
|
|
137
|
+
position: string;
|
|
138
|
+
kind_data: Record<string, unknown>;
|
|
139
|
+
}>;
|
|
140
|
+
toUpdate: Array<{
|
|
141
|
+
id: string;
|
|
142
|
+
content: string;
|
|
143
|
+
status: string;
|
|
144
|
+
completed: boolean;
|
|
145
|
+
position: string;
|
|
146
|
+
kind_data: Record<string, unknown>;
|
|
147
|
+
}>;
|
|
148
|
+
toDelete: string[];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function planReconcile(
|
|
152
|
+
existing: WorkItem[],
|
|
153
|
+
incoming: TaskListItem[],
|
|
154
|
+
source?: string,
|
|
155
|
+
): ReconcilePlan {
|
|
156
|
+
const byKey = new Map<string, WorkItem>();
|
|
157
|
+
for (const s of existing) {
|
|
158
|
+
const mark = (s.kind_data?.task_sync as { mark?: string } | undefined)?.mark;
|
|
159
|
+
byKey.set(subtaskKey({ mark, content: "", title: s.title }), s);
|
|
160
|
+
}
|
|
161
|
+
const toCreate: ReconcilePlan["toCreate"] = [];
|
|
162
|
+
const toUpdate: ReconcilePlan["toUpdate"] = [];
|
|
163
|
+
const seen = new Set<string>();
|
|
164
|
+
for (let i = 0; i < incoming.length; i++) {
|
|
165
|
+
const item = incoming[i];
|
|
166
|
+
const content = (item.content ?? "").trim();
|
|
167
|
+
if (!content) continue;
|
|
168
|
+
const key = subtaskKey({ mark: item.mark, content, title: content });
|
|
169
|
+
const incomingStatus = STATUS_TO_WORK_ITEM[item.status] ?? "todo";
|
|
170
|
+
const completed = item.status === "completed";
|
|
171
|
+
const position = String(i);
|
|
172
|
+
const taskSync = { task_sync: { mark: item.mark, source } };
|
|
173
|
+
const match = byKey.get(key);
|
|
174
|
+
if (match) {
|
|
175
|
+
seen.add(match.id);
|
|
176
|
+
// A blocked row must survive a list the CLI cannot express it in.
|
|
177
|
+
const status = resolveProjectedStatus(match.status, incomingStatus);
|
|
178
|
+
const changed =
|
|
179
|
+
match.title !== content ||
|
|
180
|
+
match.status !== status ||
|
|
181
|
+
(match.completed ?? false) !== completed ||
|
|
182
|
+
match.position !== position;
|
|
183
|
+
if (changed) {
|
|
184
|
+
toUpdate.push({
|
|
185
|
+
id: match.id,
|
|
186
|
+
content,
|
|
187
|
+
status,
|
|
188
|
+
completed,
|
|
189
|
+
position,
|
|
190
|
+
kind_data: { ...match.kind_data, ...taskSync },
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
} else {
|
|
194
|
+
// A row being created has no prior status to preserve, so the incoming
|
|
195
|
+
// one stands unmodified.
|
|
196
|
+
toCreate.push({ content, status: incomingStatus, completed, position, kind_data: taskSync });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const toDelete = existing.filter((s) => !seen.has(s.id)).map((s) => s.id);
|
|
200
|
+
return { toCreate, toUpdate, toDelete };
|
|
201
|
+
}
|
|
@@ -81,7 +81,7 @@ export interface LaneIdentity {
|
|
|
81
81
|
*/
|
|
82
82
|
organisation_id?: string;
|
|
83
83
|
}
|
|
84
|
-
interface WorkItem {
|
|
84
|
+
export interface WorkItem {
|
|
85
85
|
id: string;
|
|
86
86
|
kind: string;
|
|
87
87
|
parent_id: string | null;
|
|
@@ -104,8 +104,6 @@ export declare function untrackedRunForPane(pane: string): {
|
|
|
104
104
|
};
|
|
105
105
|
tracked_task_id: null;
|
|
106
106
|
};
|
|
107
|
-
export declare const STATUS_TO_WORK_ITEM: Record<TaskListItem["status"], string>;
|
|
108
|
-
export declare function normalizeStatus(s: string | null): TaskListItem["status"];
|
|
109
107
|
/**
|
|
110
108
|
* Resolve the org a lane's task is created under. Explicit when the lane knows
|
|
111
109
|
* its org (a dispatched fleet lane carries one); defaults to the platform org
|
|
@@ -154,72 +152,6 @@ export declare function classifyTaskHookEvent(evt: {
|
|
|
154
152
|
hook_event_name?: unknown;
|
|
155
153
|
task_subject?: unknown;
|
|
156
154
|
}): "completed" | "created" | "tool";
|
|
157
|
-
export declare function subtaskKey(item: {
|
|
158
|
-
mark?: string;
|
|
159
|
-
content: string;
|
|
160
|
-
title: string;
|
|
161
|
-
}): string;
|
|
162
|
-
/**
|
|
163
|
-
* Pure decision for a TaskUpdate/TaskDelete: which existing subtask does this
|
|
164
|
-
* event address, and what should happen when none does. Extracted for the same
|
|
165
|
-
* reason planReconcile was — the handler should only EXECUTE, so the matching
|
|
166
|
-
* rule is unit-testable.
|
|
167
|
-
*
|
|
168
|
-
* Three resolutions:
|
|
169
|
-
* - `update` — matched by mark, or (mark absent/unstamped) by exact title.
|
|
170
|
-
* - `heal` — nothing matched but the event carries a subject, so the row can
|
|
171
|
-
* be reconstructed. Covers a TaskCreate that silently produced no
|
|
172
|
-
* subtask, which previously orphaned the task forever.
|
|
173
|
-
* - `discard` — nothing matched and there is no subject to rebuild a title from.
|
|
174
|
-
*
|
|
175
|
-
* Measured 2026-07-28: marks "1" and "3" were stamped for one lane while mark
|
|
176
|
-
* "2" was never written at all, and every later update for it was dropped as
|
|
177
|
-
* `no-matching-subtask` — 26 of 30 tasksync discards in six hours, across seven
|
|
178
|
-
* distinct lane sessions, were that one reason.
|
|
179
|
-
*/
|
|
180
|
-
export declare function resolveTaskUpdateTarget(existing: Array<{
|
|
181
|
-
id: string;
|
|
182
|
-
title: string;
|
|
183
|
-
kind_data?: Record<string, unknown> | null;
|
|
184
|
-
}>, evt: {
|
|
185
|
-
taskId: string;
|
|
186
|
-
subject?: string;
|
|
187
|
-
}): {
|
|
188
|
-
op: "update";
|
|
189
|
-
id: string;
|
|
190
|
-
} | {
|
|
191
|
-
op: "heal";
|
|
192
|
-
title: string;
|
|
193
|
-
} | {
|
|
194
|
-
op: "discard";
|
|
195
|
-
};
|
|
196
|
-
/**
|
|
197
|
-
* Pure reconciliation plan: given the lane's EXISTING subtasks and its INCOMING
|
|
198
|
-
* (current) task list, compute the create/update/delete ops that make the
|
|
199
|
-
* subtask set match the list — WITHOUT touching prisma. Extracted so the
|
|
200
|
-
* matching/status/positioning logic is unit-testable (the handler only executes
|
|
201
|
-
* the plan). Matching is by stable mark when the CLI provides one, else by
|
|
202
|
-
* content. Position follows incoming index, so a reorder is an update.
|
|
203
|
-
*/
|
|
204
|
-
export interface ReconcilePlan {
|
|
205
|
-
toCreate: Array<{
|
|
206
|
-
content: string;
|
|
207
|
-
status: string;
|
|
208
|
-
completed: boolean;
|
|
209
|
-
position: string;
|
|
210
|
-
kind_data: Record<string, unknown>;
|
|
211
|
-
}>;
|
|
212
|
-
toUpdate: Array<{
|
|
213
|
-
id: string;
|
|
214
|
-
content: string;
|
|
215
|
-
status: string;
|
|
216
|
-
completed: boolean;
|
|
217
|
-
position: string;
|
|
218
|
-
kind_data: Record<string, unknown>;
|
|
219
|
-
}>;
|
|
220
|
-
toDelete: string[];
|
|
221
|
-
}
|
|
222
|
-
export declare function planReconcile(existing: WorkItem[], incoming: TaskListItem[], source?: string): ReconcilePlan;
|
|
223
155
|
export declare const taskSyncRouter: {
|
|
224
156
|
/**
|
|
225
157
|
* Bind (create-or-confirm) the task a lane owns. Called from a SessionStart
|
|
@@ -373,4 +305,3 @@ export declare const taskSyncRouter: {
|
|
|
373
305
|
notified: boolean;
|
|
374
306
|
}>, Record<never, never>, Record<never, never>>;
|
|
375
307
|
};
|
|
376
|
-
export {};
|
|
@@ -13,11 +13,11 @@ import {
|
|
|
13
13
|
resolveTaskUpdateTarget,
|
|
14
14
|
planReconcile,
|
|
15
15
|
normalizeStatus,
|
|
16
|
+
resolveProjectedStatus,
|
|
16
17
|
subtaskKey,
|
|
17
18
|
STATUS_TO_WORK_ITEM,
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
} from "./task-sync-router.ts";
|
|
19
|
+
} from "./task-sync-projection.ts";
|
|
20
|
+
import { type TaskListItem, upsertFromTaskListInput } from "./task-sync-router.ts";
|
|
21
21
|
import * as v from "valibot";
|
|
22
22
|
|
|
23
23
|
interface WorkItemLike {
|
|
@@ -309,3 +309,41 @@ describe("task-sync upsertFromTaskListInput schema", () => {
|
|
|
309
309
|
expect(() => v.parse(upsertFromTaskListInput, { task_list: [] })).toThrow();
|
|
310
310
|
});
|
|
311
311
|
});
|
|
312
|
+
|
|
313
|
+
describe("a blocked row survives a CLI list that cannot express it", () => {
|
|
314
|
+
it("an incoming todo does NOT overwrite a blocked row", () => {
|
|
315
|
+
// The CLI emits todo/pending for every status it cannot represent, so a
|
|
316
|
+
// todo arriving for a blocked row asserts nothing about the work.
|
|
317
|
+
expect(resolveProjectedStatus("blocked", "todo")).toBe("blocked");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("real transitions the CLI CAN express still win", () => {
|
|
321
|
+
// in-progress and done are genuine user signals, not projection artifacts.
|
|
322
|
+
expect(resolveProjectedStatus("blocked", "in-progress")).toBe("in-progress");
|
|
323
|
+
expect(resolveProjectedStatus("blocked", "done")).toBe("done");
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("a non-blocked row is unaffected", () => {
|
|
327
|
+
expect(resolveProjectedStatus("todo", "in-progress")).toBe("in-progress");
|
|
328
|
+
expect(resolveProjectedStatus("in-progress", "done")).toBe("done");
|
|
329
|
+
expect(resolveProjectedStatus("done", "todo")).toBe("todo");
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("a missing current status never resurrects a block", () => {
|
|
333
|
+
expect(resolveProjectedStatus(null, "todo")).toBe("todo");
|
|
334
|
+
expect(resolveProjectedStatus(undefined, "todo")).toBe("todo");
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("planReconcile leaves a blocked subtask blocked when the list says pending", () => {
|
|
338
|
+
// The end-to-end shape: this is what re-blocked the lane three times.
|
|
339
|
+
const plan = planReconcile(
|
|
340
|
+
[{ mark: "1", content: "Deploy the fix", status: "pending" }],
|
|
341
|
+
[{ id: "row-1", title: "Deploy the fix", status: "blocked", completed: false, position: "0", kind_data: { task_sync: { mark: "1" } } }] as never,
|
|
342
|
+
"claude-code",
|
|
343
|
+
);
|
|
344
|
+
const update = plan.toUpdate.find((u) => u.id === "row-1");
|
|
345
|
+
|
|
346
|
+
// Either untouched, or updated while KEEPING blocked — never downgraded.
|
|
347
|
+
expect(update?.status ?? "blocked").toBe("blocked");
|
|
348
|
+
});
|
|
349
|
+
});
|
|
@@ -41,6 +41,15 @@ import { getPlatformOrgId } from "@teamscala/db/registry/platform";
|
|
|
41
41
|
import { authed, type Context } from "./auth-router.ts";
|
|
42
42
|
import { notifyLaneOfTaskChange } from "./task-sync-dispatch.ts";
|
|
43
43
|
import * as v from "valibot";
|
|
44
|
+
import {
|
|
45
|
+
STATUS_TO_WORK_ITEM,
|
|
46
|
+
normalizeStatus,
|
|
47
|
+
resolveProjectedStatus,
|
|
48
|
+
subtaskKey,
|
|
49
|
+
resolveTaskUpdateTarget,
|
|
50
|
+
planReconcile,
|
|
51
|
+
type ReconcilePlan,
|
|
52
|
+
} from "./task-sync-projection.ts";
|
|
44
53
|
|
|
45
54
|
/** A lane's native task-list item, normalized. Every CLI adapter maps to this. */
|
|
46
55
|
export interface TaskListItem {
|
|
@@ -92,7 +101,7 @@ export interface LaneIdentity {
|
|
|
92
101
|
organisation_id?: string;
|
|
93
102
|
}
|
|
94
103
|
|
|
95
|
-
interface WorkItem {
|
|
104
|
+
export interface WorkItem {
|
|
96
105
|
id: string;
|
|
97
106
|
kind: string;
|
|
98
107
|
parent_id: string | null;
|
|
@@ -127,26 +136,6 @@ export function untrackedRunForPane(pane: string) {
|
|
|
127
136
|
};
|
|
128
137
|
}
|
|
129
138
|
|
|
130
|
-
// TodoWrite-style status (the normalized form) → work_items.status (DB enum).
|
|
131
|
-
// The DB enum uses a hyphen for the active state ("in-progress"); see
|
|
132
|
-
// COLUMN_ENUMS["work_items.status"] in db-validation.
|
|
133
|
-
export const STATUS_TO_WORK_ITEM: Record<TaskListItem["status"], string> = {
|
|
134
|
-
pending: "todo",
|
|
135
|
-
in_progress: "in-progress",
|
|
136
|
-
completed: "done",
|
|
137
|
-
};
|
|
138
|
-
// work_items.status → normalized. Unknown/internal statuses collapse to pending.
|
|
139
|
-
const WORK_ITEM_TO_STATUS: Record<string, TaskListItem["status"]> = {
|
|
140
|
-
todo: "pending",
|
|
141
|
-
"in-progress": "in_progress",
|
|
142
|
-
active: "in_progress",
|
|
143
|
-
done: "completed",
|
|
144
|
-
completed: "completed",
|
|
145
|
-
};
|
|
146
|
-
|
|
147
|
-
export function normalizeStatus(s: string | null): TaskListItem["status"] {
|
|
148
|
-
return (s && WORK_ITEM_TO_STATUS[s]) || "pending";
|
|
149
|
-
}
|
|
150
139
|
|
|
151
140
|
/**
|
|
152
141
|
* Resolve the org a lane's task is created under. Explicit when the lane knows
|
|
@@ -372,121 +361,6 @@ export function classifyTaskHookEvent(evt: {
|
|
|
372
361
|
return "tool";
|
|
373
362
|
}
|
|
374
363
|
|
|
375
|
-
export function subtaskKey(item: { mark?: string; content: string; title: string }): string {
|
|
376
|
-
return item.mark || item.content || item.title;
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
/**
|
|
380
|
-
* Pure decision for a TaskUpdate/TaskDelete: which existing subtask does this
|
|
381
|
-
* event address, and what should happen when none does. Extracted for the same
|
|
382
|
-
* reason planReconcile was — the handler should only EXECUTE, so the matching
|
|
383
|
-
* rule is unit-testable.
|
|
384
|
-
*
|
|
385
|
-
* Three resolutions:
|
|
386
|
-
* - `update` — matched by mark, or (mark absent/unstamped) by exact title.
|
|
387
|
-
* - `heal` — nothing matched but the event carries a subject, so the row can
|
|
388
|
-
* be reconstructed. Covers a TaskCreate that silently produced no
|
|
389
|
-
* subtask, which previously orphaned the task forever.
|
|
390
|
-
* - `discard` — nothing matched and there is no subject to rebuild a title from.
|
|
391
|
-
*
|
|
392
|
-
* Measured 2026-07-28: marks "1" and "3" were stamped for one lane while mark
|
|
393
|
-
* "2" was never written at all, and every later update for it was dropped as
|
|
394
|
-
* `no-matching-subtask` — 26 of 30 tasksync discards in six hours, across seven
|
|
395
|
-
* distinct lane sessions, were that one reason.
|
|
396
|
-
*/
|
|
397
|
-
export function resolveTaskUpdateTarget(
|
|
398
|
-
existing: Array<{ id: string; title: string; kind_data?: Record<string, unknown> | null }>,
|
|
399
|
-
evt: { taskId: string; subject?: string },
|
|
400
|
-
): { op: "update"; id: string } | { op: "heal"; title: string } | { op: "discard" } {
|
|
401
|
-
const byMark = existing.find(
|
|
402
|
-
(s) =>
|
|
403
|
-
evt.taskId &&
|
|
404
|
-
(s.kind_data as { task_sync?: { mark?: string } } | null)?.task_sync?.mark === evt.taskId,
|
|
405
|
-
);
|
|
406
|
-
if (byMark) return { op: "update", id: byMark.id };
|
|
407
|
-
const subject = (evt.subject ?? "").trim();
|
|
408
|
-
if (subject) {
|
|
409
|
-
const byTitle = existing.find((s) => s.title === subject);
|
|
410
|
-
if (byTitle) return { op: "update", id: byTitle.id };
|
|
411
|
-
return { op: "heal", title: subject };
|
|
412
|
-
}
|
|
413
|
-
return { op: "discard" };
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
/**
|
|
417
|
-
* Pure reconciliation plan: given the lane's EXISTING subtasks and its INCOMING
|
|
418
|
-
* (current) task list, compute the create/update/delete ops that make the
|
|
419
|
-
* subtask set match the list — WITHOUT touching prisma. Extracted so the
|
|
420
|
-
* matching/status/positioning logic is unit-testable (the handler only executes
|
|
421
|
-
* the plan). Matching is by stable mark when the CLI provides one, else by
|
|
422
|
-
* content. Position follows incoming index, so a reorder is an update.
|
|
423
|
-
*/
|
|
424
|
-
export interface ReconcilePlan {
|
|
425
|
-
toCreate: Array<{
|
|
426
|
-
content: string;
|
|
427
|
-
status: string;
|
|
428
|
-
completed: boolean;
|
|
429
|
-
position: string;
|
|
430
|
-
kind_data: Record<string, unknown>;
|
|
431
|
-
}>;
|
|
432
|
-
toUpdate: Array<{
|
|
433
|
-
id: string;
|
|
434
|
-
content: string;
|
|
435
|
-
status: string;
|
|
436
|
-
completed: boolean;
|
|
437
|
-
position: string;
|
|
438
|
-
kind_data: Record<string, unknown>;
|
|
439
|
-
}>;
|
|
440
|
-
toDelete: string[];
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
export function planReconcile(
|
|
444
|
-
existing: WorkItem[],
|
|
445
|
-
incoming: TaskListItem[],
|
|
446
|
-
source?: string,
|
|
447
|
-
): ReconcilePlan {
|
|
448
|
-
const byKey = new Map<string, WorkItem>();
|
|
449
|
-
for (const s of existing) {
|
|
450
|
-
const mark = (s.kind_data?.task_sync as { mark?: string } | undefined)?.mark;
|
|
451
|
-
byKey.set(subtaskKey({ mark, content: "", title: s.title }), s);
|
|
452
|
-
}
|
|
453
|
-
const toCreate: ReconcilePlan["toCreate"] = [];
|
|
454
|
-
const toUpdate: ReconcilePlan["toUpdate"] = [];
|
|
455
|
-
const seen = new Set<string>();
|
|
456
|
-
for (let i = 0; i < incoming.length; i++) {
|
|
457
|
-
const item = incoming[i];
|
|
458
|
-
const content = (item.content ?? "").trim();
|
|
459
|
-
if (!content) continue;
|
|
460
|
-
const key = subtaskKey({ mark: item.mark, content, title: content });
|
|
461
|
-
const status = STATUS_TO_WORK_ITEM[item.status] ?? "todo";
|
|
462
|
-
const completed = item.status === "completed";
|
|
463
|
-
const position = String(i);
|
|
464
|
-
const taskSync = { task_sync: { mark: item.mark, source } };
|
|
465
|
-
const match = byKey.get(key);
|
|
466
|
-
if (match) {
|
|
467
|
-
seen.add(match.id);
|
|
468
|
-
const changed =
|
|
469
|
-
match.title !== content ||
|
|
470
|
-
match.status !== status ||
|
|
471
|
-
(match.completed ?? false) !== completed ||
|
|
472
|
-
match.position !== position;
|
|
473
|
-
if (changed) {
|
|
474
|
-
toUpdate.push({
|
|
475
|
-
id: match.id,
|
|
476
|
-
content,
|
|
477
|
-
status,
|
|
478
|
-
completed,
|
|
479
|
-
position,
|
|
480
|
-
kind_data: { ...match.kind_data, ...taskSync },
|
|
481
|
-
});
|
|
482
|
-
}
|
|
483
|
-
} else {
|
|
484
|
-
toCreate.push({ content, status, completed, position, kind_data: taskSync });
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
const toDelete = existing.filter((s) => !seen.has(s.id)).map((s) => s.id);
|
|
488
|
-
return { toCreate, toUpdate, toDelete };
|
|
489
|
-
}
|
|
490
364
|
|
|
491
365
|
/**
|
|
492
366
|
* Execute a reconcile against prisma for a lane's task — the shared write path
|
|
@@ -736,6 +610,13 @@ export const taskSyncRouter = {
|
|
|
736
610
|
const { ORPCError } = await import("@orpc/server");
|
|
737
611
|
throw new ORPCError("NOT_FOUND", { message: err.message });
|
|
738
612
|
}
|
|
613
|
+
// The 500 body is masked by oRPC (INTERNAL_SERVER_ERROR), so without
|
|
614
|
+
// this the cause is invisible — measured 2026-07-30:
|
|
615
|
+
// upsertFromTaskList 500ing consistently with no surfaced error,
|
|
616
|
+
// blocking task-list projection + the watchdog for every lane. Log
|
|
617
|
+
// the actual error before re-throwing (protective-layers-fail-
|
|
618
|
+
// explicitly: a failure with no observability is the worst shape).
|
|
619
|
+
log.error("[task-sync] upsertFromTaskList FAILED", { session, err: err instanceof Error ? `${err.name}: ${err.message}` : String(err) });
|
|
739
620
|
throw err;
|
|
740
621
|
}
|
|
741
622
|
}),
|
|
@@ -17,7 +17,7 @@ import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schem
|
|
|
17
17
|
export type PgBossJobHandler = (ctx: {
|
|
18
18
|
config: ScheduledJobConfig;
|
|
19
19
|
}) => Promise<void> | void;
|
|
20
|
-
export declare const pgBossHandlerRegistry:
|
|
20
|
+
export declare const pgBossHandlerRegistry: import("@teamscala/generic-registry/create-registry").Registry<PgBossJobHandler>;
|
|
21
21
|
/**
|
|
22
22
|
* Register a handler for a `scheduled_job.handler_slug`. Idempotent on re-import
|
|
23
23
|
* (createRegistry.register overwrites silently — last registration wins).
|
|
@@ -7,8 +7,8 @@ type GraphCache = {
|
|
|
7
7
|
ttlMs?: number;
|
|
8
8
|
}): void;
|
|
9
9
|
};
|
|
10
|
-
export declare const staticShellGraphCache:
|
|
11
|
-
export declare const paramRouteGraphCache:
|
|
10
|
+
export declare const staticShellGraphCache: import("@teamscala/cache/cache").ManagedCache<unknown>;
|
|
11
|
+
export declare const paramRouteGraphCache: import("@teamscala/cache/cache").ManagedCache<unknown>;
|
|
12
12
|
/**
|
|
13
13
|
* Stale-while-revalidate read of a cached loader graph.
|
|
14
14
|
*
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Co-located tests for the resource-not-found public API: the error class +
|
|
7
|
+
* the response builder are pure (no S3), so the behaviour is exercised directly.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, it } from "bun:test";
|
|
10
|
+
import { PrimaryResourceMissingError, buildResourceNotFoundResponse } from "./resource-not-found.ts";
|
|
11
|
+
|
|
12
|
+
describe("PrimaryResourceMissingError", () => {
|
|
13
|
+
it("is an Error carrying resourceType + httpStatus", () => {
|
|
14
|
+
const err = new PrimaryResourceMissingError("project", 404);
|
|
15
|
+
expect(err).toBeInstanceOf(Error);
|
|
16
|
+
expect(err.resourceType).toBe("project");
|
|
17
|
+
expect(err.httpStatus).toBe(404);
|
|
18
|
+
expect(err.empty).toBe(false);
|
|
19
|
+
expect(err.message).toContain("project");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("defaults httpStatus to 404 + supports the empty variant", () => {
|
|
23
|
+
const dead = new PrimaryResourceMissingError("roadmap");
|
|
24
|
+
expect(dead.httpStatus).toBe(404);
|
|
25
|
+
const empty = new PrimaryResourceMissingError("roadmap", 200, { empty: true });
|
|
26
|
+
expect(empty.httpStatus).toBe(200);
|
|
27
|
+
expect(empty.empty).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("buildResourceNotFoundResponse", () => {
|
|
32
|
+
it("returns a 404 Response for a dead link", () => {
|
|
33
|
+
const res = buildResourceNotFoundResponse("project");
|
|
34
|
+
expect(res).toBeInstanceOf(Response);
|
|
35
|
+
expect(res.status).toBe(404);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("returns a 200 Response for the bare root (no resource selected)", () => {
|
|
39
|
+
const res = buildResourceNotFoundResponse("project", undefined, 200);
|
|
40
|
+
expect(res.status).toBe(200);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("renders the empty-page surface when opts.empty", () => {
|
|
44
|
+
const res = buildResourceNotFoundResponse("project", undefined, 200, { empty: true });
|
|
45
|
+
expect(res.status).toBe(200);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* floor that must always exist.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
-
import { getClient } from "@teamscala/
|
|
31
|
+
import { getClient } from "@teamscala/object-storage/create/client/get-client";
|
|
32
32
|
import { createCache } from "@teamscala/cache/create-cache";
|
|
33
33
|
import { getServerClient } from "@teamscala/orpc/server-client-registry";
|
|
34
34
|
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|