@desplega.ai/agent-swarm 2.4.0 → 2.4.2
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/README.md +1 -1
- package/dist/{acp-adapter-kedstxx6.js → acp-adapter-dv7hmx4r.js} +9 -9
- package/dist/{actions-vget6r1k.js → actions-51nc1gny.js} +21 -21
- package/dist/{amp-adapter-xeb5mktv.js → amp-adapter-abp5tdx8.js} +4 -4
- package/dist/{app-mqap8t8m.js → app-9h14qvr6.js} +3 -3
- package/dist/{assistant-h0gv97mv.js → assistant-wv1gz3mt.js} +22 -22
- package/dist/{boot-reembed-qr8a894z.js → boot-reembed-6yn53ms9.js} +1 -1
- package/dist/{boot-reembed-rj85jjhw.js → boot-reembed-7fcka4z5.js} +1 -1
- package/dist/{boot-scrub-sweep-eyfvn6v9.js → boot-scrub-sweep-pm1580ax.js} +4 -4
- package/dist/{boot-seeds-xs9a8dx1.js → boot-seeds-g5hfc1x8.js} +4 -4
- package/dist/{claude-adapter-bm23cye7.js → claude-adapter-gf3kxhf0.js} +9 -9
- package/dist/{claude-managed-adapter-1mwrsm9d.js → claude-managed-adapter-h2y889ge.js} +2 -2
- package/dist/{claude-managed-setup-gkfgks4j.js → claude-managed-setup-h00hsgtz.js} +2 -2
- package/dist/{claude-sdk-session-apb4ypz3.js → claude-sdk-session-s2k1qd8y.js} +13 -13
- package/dist/{cli-tj4j2ts4.js → cli-02qm7src.js} +8 -8
- package/dist/{cli-1cevz46c.js → cli-0qvtffe9.js} +6 -6
- package/dist/{cli-avmqhatn.js → cli-10hhg0km.js} +8 -8
- package/dist/{cli-7gh7020n.js → cli-1dmcq2eh.js} +1 -1
- package/dist/{cli-kz116kvw.js → cli-239yjk27.js} +7 -7
- package/dist/{cli-7ayprxp7.js → cli-2dfbv8qr.js} +8 -0
- package/dist/{cli-a49vpbf3.js → cli-5kqnemen.js} +1 -1
- package/dist/{cli-x719j3vs.js → cli-5rk36sjf.js} +442 -205
- package/dist/{cli-fb6t92v8.js → cli-6x9ma5w5.js} +1 -1
- package/dist/{cli-c8fwpgqb.js → cli-7225gwj7.js} +2 -2
- package/dist/{cli-5s5hx57y.js → cli-7awf1fp8.js} +1 -1
- package/dist/{cli-bn26xbcs.js → cli-8nyhf7za.js} +1 -1
- package/dist/{cli-ywyrffff.js → cli-8sxqsbbq.js} +1 -1
- package/dist/{cli-rsd7ga7v.js → cli-8zwe9mpk.js} +11 -11
- package/dist/{cli-y83zk5sb.js → cli-a0kcty51.js} +4 -4
- package/dist/{cli-8b9wvjps.js → cli-a4gvwttf.js} +4 -4
- package/dist/{cli-jhp7w1fy.js → cli-a5ebc95n.js} +2 -2
- package/dist/{cli-mvg2hf8p.js → cli-ahtpy9kf.js} +14 -7
- package/dist/{cli-x1p5dhq2.js → cli-bcj753sq.js} +3 -3
- package/dist/{cli-p22zdvnt.js → cli-bn0rewsy.js} +3 -3
- package/dist/{cli-p1m2b6gc.js → cli-d2mb5s8a.js} +7 -7
- package/dist/{cli-rg7ntypm.js → cli-d3qqdy4c.js} +456 -94
- package/dist/{cli-c5sbmw1v.js → cli-d82btycs.js} +94 -15
- package/dist/{cli-02vhhssk.js → cli-efaqv1c7.js} +5 -5
- package/dist/{cli-vjfpshn3.js → cli-ejs3m1sx.js} +1 -1
- package/dist/{cli-kk2r80n5.js → cli-faypxz7q.js} +4 -4
- package/dist/{cli-a1esmmee.js → cli-fpmsqq59.js} +7 -2
- package/dist/{cli-wrv09v7q.js → cli-ftyypbg5.js} +1 -1
- package/dist/{cli-zvze9rbm.js → cli-fyzjmseb.js} +3 -3
- package/dist/{cli-8rwvksbc.js → cli-g0hgtz4p.js} +5 -5
- package/dist/{cli-nrrg0fvy.js → cli-h38rjv87.js} +133 -12
- package/dist/{cli-tg9ksbn8.js → cli-h93c9vb3.js} +2 -2
- package/dist/{cli-eqgfs202.js → cli-hqhzg1qq.js} +2 -2
- package/dist/{cli-bhwa8rzc.js → cli-hyfw2j1e.js} +13 -12
- package/dist/{cli-3wjs22x3.js → cli-j4rqdf2n.js} +2 -2
- package/dist/{cli-6ftrrwm1.js → cli-n85dqg6d.js} +5 -5
- package/dist/{cli-8cxkw1f5.js → cli-nmqdc2t3.js} +2 -2
- package/dist/{cli-4fhyq711.js → cli-nn47d7e8.js} +13 -13
- package/dist/{cli-x3en6ct3.js → cli-nwyw3zhg.js} +1 -1
- package/dist/{cli-3avfh5az.js → cli-pemywhfq.js} +2 -2
- package/dist/{cli-xfe63zx3.js → cli-pkht5hap.js} +2 -2
- package/dist/{cli-sppc0cmw.js → cli-pnp2j5zg.js} +2 -2
- package/dist/{cli-wk8ccrz6.js → cli-pw7v69vp.js} +2 -2
- package/dist/{cli-tgvpmnwz.js → cli-r99kc9yq.js} +5 -5
- package/dist/{cli-bfc3k1x5.js → cli-rdyw3mct.js} +9 -9
- package/dist/{cli-b80a1100.js → cli-s6vr7r2d.js} +2 -2
- package/dist/{cli-zv4vy2b0.js → cli-s95dknxh.js} +1 -1
- package/dist/{cli-ttaz6c7m.js → cli-saw9a6f6.js} +6 -5
- package/dist/{cli-mz1ezzzh.js → cli-shvfedvm.js} +3 -3
- package/dist/{cli-rdr166ht.js → cli-smvcw9z5.js} +1 -1
- package/dist/{cli-2rwet3r7.js → cli-sv5gb0yb.js} +1 -1
- package/dist/{cli-5ff2sjyt.js → cli-t30yj95q.js} +2 -2
- package/dist/{cli-ptkpaqfy.js → cli-wt645v7q.js} +1 -1
- package/dist/{cli-f3nx1h8z.js → cli-wxbn3yfx.js} +2 -2
- package/dist/{cli-wyrr38xm.js → cli-x5mq1fyq.js} +1 -1
- package/dist/{cli-ccn0eybf.js → cli-xbzvet96.js} +6 -6
- package/dist/{cli-4mkvwgzr.js → cli-xf234ws8.js} +1 -1
- package/dist/{cli-nme7wery.js → cli-y2x52gs2.js} +46 -13
- package/dist/{cli-ffbrkh1r.js → cli-y8n5k515.js} +46 -38
- package/dist/{cli-ecshf504.js → cli-z4wd2ftv.js} +7 -7
- package/dist/{cli-dsgj3e7a.js → cli-zfg9enh9.js} +2 -2
- package/dist/{cli-yxqag76b.js → cli-zw88zjba.js} +1 -1
- package/dist/cli.js +20 -20
- package/dist/{codex-adapter-pp2gq8gr.js → codex-adapter-ttygw382.js} +6 -6
- package/dist/{codex-hook-qwzccd64.js → codex-hook-x7nejyrr.js} +7 -4
- package/dist/{codex-login-35p4sk85.js → codex-login-m819c41r.js} +1 -1
- package/dist/{codex-session-runner-0vzn09t8.js → codex-session-runner-60b58vv3.js} +7 -7
- package/dist/{commands-psqhkttb.js → commands-edmdwa4x.js} +2 -2
- package/dist/{controller-t20vz2k8.js → controller-m263fg43.js} +4 -4
- package/dist/{controllers-connect-gkxmqw9d.js → controllers-connect-5fym8sw1.js} +23 -23
- package/dist/{cursor-adapter-3v542qe5.js → cursor-adapter-cz6nm6mj.js} +3 -3
- package/dist/{db-nc1rd869.js → db-2y0mgstc.js} +3 -3
- package/dist/{db-2xbyenjr.js → db-nn0hcmx5.js} +6 -2
- package/dist/{devin-adapter-7egzmt0k.js → devin-adapter-zgqadvnm.js} +1 -1
- package/dist/{dispatcher-ewceb8ts.js → dispatcher-0a9wwjcx.js} +20 -20
- package/dist/{docker-njavd3fb.js → docker-fkgtr75t.js} +2 -2
- package/dist/{dsh-adapter-75xwh78t.js → dsh-adapter-3n3net33.js} +3 -3
- package/dist/{e2b-5r6a5jeb.js → e2b-7am6tmq6.js} +2 -2
- package/dist/{e2b-pw1nvtmh.js → e2b-thdcxtjd.js} +2 -2
- package/dist/{easter-egg-3d-xsc47jwf.js → easter-egg-3d-5cgynaja.js} +1 -1
- package/dist/{event-bus-101fenkh.js → event-bus-9vpk8gmq.js} +1 -1
- package/dist/{execute-gzmw1mnw.js → execute-t4e6m3rw.js} +1 -1
- package/dist/{external-channel-a5dsmxn4.js → external-channel-zhvt95n3.js} +2 -2
- package/dist/{grok-adapter-vj88xr23.js → grok-adapter-qnr1hp2y.js} +10 -10
- package/dist/handlers-dkcarkd0.js +39 -0
- package/dist/{heartbeat-t2m9r3wa.js → heartbeat-gz97r7ac.js} +23 -23
- package/dist/{hook-xa677f8r.js → hook-1mmdgjqs.js} +16 -9
- package/dist/{hook-8femx1bg.js → hook-x8rthca4.js} +6 -6
- package/dist/{http-xpmmq028.js → http-r8f85xr9.js} +274 -160
- package/dist/index-5jecgp3n.js +41 -0
- package/dist/index-aex0eba9.js +40 -0
- package/dist/{index-syztqdt6.js → index-g6tbb6ve.js} +1 -1
- package/dist/{index-cdzyfrsp.js → index-smxyk04s.js} +1 -1
- package/dist/{index-dg2tmj99.js → index-vzvfqzfj.js} +20 -20
- package/dist/{index-s7ckdajq.js → index-y7rbcrc1.js} +10 -10
- package/dist/{keepalive-3nr4st3r.js → keepalive-64c0f34c.js} +4 -4
- package/dist/lead-b1da0f97.js +78 -0
- package/dist/{maintenance-verwpe2c.js → maintenance-mnxpar12.js} +1 -1
- package/dist/{oauth-refresh-sweep-9q2t0y3q.js → oauth-refresh-sweep-36hs2v4h.js} +3 -3
- package/dist/{onboard-aytqedmq.js → onboard-xes8de61.js} +3 -3
- package/dist/{opencode-adapter-f4hryjny.js → opencode-adapter-3s7ax02e.js} +7 -7
- package/dist/{otel-impl-hckw4wt1.js → otel-impl-fzc57kcq.js} +2 -2
- package/dist/{pi-mono-adapter-hxby0ce1.js → pi-mono-adapter-ytp7gz8b.js} +22 -11
- package/dist/{pricing-refresh-5nvme3sm.js → pricing-refresh-1nek2w7b.js} +3 -3
- package/dist/{rbac-roles-vbpy21mg.js → rbac-roles-sqefb5hw.js} +2 -2
- package/dist/{rbac-roles-pd8c26m1.js → rbac-roles-ttyfaep2.js} +1 -1
- package/dist/{render-v2-7nqgvk2q.js → render-v2-vb527jwn.js} +3 -3
- package/dist/{resume-b6djcj3c.js → resume-jcdzg1bn.js} +10 -8
- package/dist/{runtime-txrxc9za.js → runtime-xe3aqy4x.js} +1 -1
- package/dist/{session-access-fc3f9y12.js → session-access-zsp4wsp0.js} +4 -4
- package/dist/{setup-n39szm6b.js → setup-1nb2zqd3.js} +1 -1
- package/dist/{virtual-modules-wyp2k6z3.js → virtual-modules-4r3zkc20.js} +2 -2
- package/dist/{work-objects-jem2fre0.js → work-objects-61w08fwr.js} +23 -23
- package/dist/worker-c1e68hpa.js +78 -0
- package/dist/{x-8ewt8gxn.js → x-y45mthwn.js} +2 -2
- package/openapi.json +491 -52
- package/package.json +3 -3
- package/src/apps/action-runner.ts +146 -0
- package/src/apps/catalog.generated.json +3 -2
- package/src/apps/definition.ts +152 -4
- package/src/apps/row-store.ts +23 -9
- package/src/apps/schema-migrate.ts +14 -6
- package/src/apps/script-types.ts +2 -1
- package/src/apps/sync.ts +20 -19
- package/src/be/db/tasks/read.ts +112 -0
- package/src/be/db.ts +16 -4
- package/src/be/fleet-benchmark-stats.ts +10 -5
- package/src/be/models-catalog.ts +4 -0
- package/src/be/onboarding.ts +6 -0
- package/src/be/scripts/typecheck.ts +13 -0
- package/src/be/swarm-config-guard.ts +25 -1
- package/src/be/telemetry-enable.ts +1 -1
- package/src/commands/provider-credentials.ts +12 -2
- package/src/commands/runner.ts +3 -0
- package/src/heartbeat/heartbeat.ts +2 -2
- package/src/hooks/codex-hook.ts +6 -2
- package/src/hooks/hook.ts +7 -0
- package/src/hooks/pr-body-guard.ts +38 -11
- package/src/hooks/pr-room-guard.ts +88 -0
- package/src/http/agents.ts +1 -0
- package/src/http/apps.ts +99 -183
- package/src/http/fleet-benchmark.ts +79 -30
- package/src/http/global-config.ts +7 -0
- package/src/http/ranking.ts +24 -40
- package/src/http/status.ts +49 -43
- package/src/http/tasks.ts +49 -1
- package/src/http/telemetry-ui.ts +1 -1
- package/src/http/used-by.ts +55 -19
- package/src/http/users.ts +2 -2
- package/src/providers/acp-targets.ts +1 -1
- package/src/providers/amp-adapter.ts +1 -1
- package/src/providers/pi-mono-adapter.ts +15 -0
- package/src/providers/reasoning-effort.ts +2 -1
- package/src/providers/types.ts +2 -0
- package/src/ranking/telemetry-ranking.ts +5 -33
- package/src/scripts-runtime/sdk-allowlist.ts +7 -0
- package/src/scripts-runtime/types/stdlib.d.ts +31 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +31 -0
- package/src/server.ts +4 -0
- package/src/telemetry-context.ts +19 -1
- package/src/telemetry-contract/EVENTS.md +42 -8
- package/src/telemetry-contract/SOURCE.json +6 -5
- package/src/telemetry-contract/catalog.json +225 -5
- package/src/telemetry-contract/ranking/fixtures/valid/no-claim.json +10 -0
- package/src/telemetry-contract/ranking/listing.v1.schema.json +2 -2
- package/src/telemetry-contract/types.gen.ts +225 -27
- package/src/telemetry-identity.ts +47 -9
- package/src/telemetry-snapshot.ts +9 -1
- package/src/telemetry-ui-events.ts +9 -2
- package/src/telemetry.ts +29 -6
- package/src/tests/app-action-run-tool.test.ts +178 -0
- package/src/tests/app-row-tools.test.ts +385 -0
- package/src/tests/apps-json-column.test.ts +611 -0
- package/src/tests/apps-script-types.test.ts +2 -0
- package/src/tests/apps-sync-engine.test.ts +42 -0
- package/src/tests/check-pr-body.test.ts +0 -23
- package/src/tests/credential-check.test.ts +38 -0
- package/src/tests/db-task-read-contract.test.ts +4 -0
- package/src/tests/db-task-write-contract.test.ts +2 -0
- package/src/tests/docs-index-footprint.test.js +2 -2
- package/src/tests/fleet-benchmark.test.ts +122 -2
- package/src/tests/http-users.test.ts +62 -1
- package/src/tests/model-catalog-table.test.ts +63 -0
- package/src/tests/model-profiles-api.test.ts +19 -7
- package/src/tests/models-catalog.test.ts +50 -0
- package/src/tests/onboarding.test.ts +21 -0
- package/src/tests/org-website.test.ts +62 -0
- package/src/tests/pi-mono-adapter.test.ts +23 -0
- package/src/tests/pr-body-leaks.test.ts +4 -0
- package/src/tests/pr-room-guard.test.ts +242 -0
- package/src/tests/ranking-listing.test.ts +8 -23
- package/src/tests/ranking-route.test.ts +49 -47
- package/src/tests/reasoning-effort.test.ts +39 -3
- package/src/tests/runner-harness-telemetry-env.test.ts +62 -1
- package/src/tests/status.test.ts +158 -9
- package/src/tests/swarm-tool-result-gate.test.ts +2 -4
- package/src/tests/task-lineage.test.ts +180 -0
- package/src/tests/telemetry-context.test.ts +128 -2
- package/src/tests/telemetry-init.test.ts +62 -5
- package/src/tests/telemetry-ui-route.test.ts +123 -0
- package/src/tests/tool-annotations.test.ts +3 -2
- package/src/tests/used-by-route.test.ts +144 -10
- package/src/tests/user-token-rest-auth.test.ts +38 -0
- package/src/tests/workflow-agent-task-retry.test.ts +130 -4
- package/src/tests/workflow-wait-recovery.test.ts +116 -4
- package/src/tools/app-action-run.ts +63 -0
- package/src/tools/app-rows.ts +239 -0
- package/src/tools/model-catalog/index.ts +1 -1
- package/src/tools/tool-config.ts +8 -0
- package/src/tools/utils.ts +1 -1
- package/src/tools/workflows/retry-workflow-run.ts +1 -1
- package/src/types.ts +17 -0
- package/src/utils/company-profile.ts +24 -0
- package/src/utils/org-website.ts +58 -21
- package/src/workflows/recovery.ts +10 -22
- package/src/workflows/resume.ts +16 -0
- package/src/workflows/task-step-routing.ts +47 -7
- package/templates/skills/apps/SKILL.md +44 -4
- package/templates/skills/apps/content.md +44 -4
- package/dist/handlers-zbs90bny.js +0 -39
- package/dist/index-8pwc1jc8.js +0 -40
- package/dist/index-nxa6me0q.js +0 -41
- package/dist/lead-td8h2sfn.js +0 -78
- package/dist/worker-wr6xgfr2.js +0 -78
- package/src/tests/check-ranking-entry.test.ts +0 -146
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import {
|
|
4
|
+
createServer as createHttpServer,
|
|
5
|
+
type IncomingMessage,
|
|
6
|
+
type Server,
|
|
7
|
+
type ServerResponse,
|
|
8
|
+
} from "node:http";
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_JSON_COLUMN_MAX_BYTES,
|
|
11
|
+
jsonColumnMaxBytes,
|
|
12
|
+
jsonColumnSizeLabel,
|
|
13
|
+
type ModelDef,
|
|
14
|
+
parseAppDefinition,
|
|
15
|
+
} from "../apps/definition";
|
|
16
|
+
import { AppRowValidationError, createAppRow, getAppRow, patchAppRow } from "../apps/row-store";
|
|
17
|
+
import { createApp, getApp } from "../apps/store";
|
|
18
|
+
import { closeDb, initDb } from "../be/db";
|
|
19
|
+
import { APPS_JSON_COLUMN_MAX_BYTES_BOUNDS, validateConfigValue } from "../be/swarm-config-guard";
|
|
20
|
+
import { handleApps, selectAppRows } from "../http/apps";
|
|
21
|
+
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
22
|
+
import { listenOnFreePort } from "./test-net";
|
|
23
|
+
|
|
24
|
+
const TEST_DB_PATH = `/private/tmp/test-apps-json-column-${process.pid}.sqlite`;
|
|
25
|
+
const AGENT_ID = crypto.randomUUID();
|
|
26
|
+
let server: Server;
|
|
27
|
+
let base = "";
|
|
28
|
+
|
|
29
|
+
function createTestServer(): Server {
|
|
30
|
+
return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
31
|
+
res.setHeader("Content-Type", "application/json");
|
|
32
|
+
const pathSegments = getPathSegments(req.url || "");
|
|
33
|
+
const queryParams = parseQueryParams(req.url || "");
|
|
34
|
+
const myAgentId = req.headers["x-agent-id"] as string | undefined;
|
|
35
|
+
const handled = await handleApps(req, res, pathSegments, queryParams, myAgentId);
|
|
36
|
+
if (!handled) {
|
|
37
|
+
res.writeHead(404);
|
|
38
|
+
res.end(JSON.stringify({ error: "not found" }));
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function request<T>(
|
|
44
|
+
path: string,
|
|
45
|
+
init: RequestInit = {},
|
|
46
|
+
): Promise<{ status: number; body: T }> {
|
|
47
|
+
const response = await fetch(`${base}${path}`, {
|
|
48
|
+
...init,
|
|
49
|
+
headers: {
|
|
50
|
+
"Content-Type": "application/json",
|
|
51
|
+
"X-Agent-ID": AGENT_ID,
|
|
52
|
+
...init.headers,
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
return { status: response.status, body: (await response.json()) as T };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
beforeAll(async () => {
|
|
59
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
60
|
+
try {
|
|
61
|
+
await unlink(`${TEST_DB_PATH}${suffix}`);
|
|
62
|
+
} catch {}
|
|
63
|
+
}
|
|
64
|
+
initDb(TEST_DB_PATH);
|
|
65
|
+
server = createTestServer();
|
|
66
|
+
const port = await listenOnFreePort(server, "127.0.0.1");
|
|
67
|
+
base = `http://127.0.0.1:${port}`;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
afterAll(async () => {
|
|
71
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
72
|
+
closeDb();
|
|
73
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
74
|
+
try {
|
|
75
|
+
await unlink(`${TEST_DB_PATH}${suffix}`);
|
|
76
|
+
} catch {}
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Minimal valid definition wrapper — mirrors the shape used in apps-spike.test.ts.
|
|
81
|
+
function defWith(models: Record<string, unknown>): Record<string, unknown> {
|
|
82
|
+
return {
|
|
83
|
+
models,
|
|
84
|
+
pages: {
|
|
85
|
+
main: {
|
|
86
|
+
root: "root",
|
|
87
|
+
elements: {
|
|
88
|
+
root: { type: "Container", props: {} },
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
defaultPage: "main",
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const NOTE_COLUMNS = {
|
|
97
|
+
title: { kind: "string", required: true },
|
|
98
|
+
payload: { kind: "json", required: true },
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* `{"s":"<ascii>"}` serializes to (body length + 8) bytes, so the boundary can
|
|
103
|
+
* be pinned to the byte: `jsonOfSerializedBytes(DEFAULT_JSON_COLUMN_MAX_BYTES)` is the
|
|
104
|
+
* largest value a json column accepts.
|
|
105
|
+
*/
|
|
106
|
+
function jsonOfSerializedBytes(bytes: number): { s: string } {
|
|
107
|
+
return { s: "x".repeat(bytes - 8) };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function expectDefinitionIssue(
|
|
111
|
+
definition: Record<string, unknown>,
|
|
112
|
+
issue: { path: string; message: unknown },
|
|
113
|
+
): Promise<void> {
|
|
114
|
+
const result = await parseAppDefinition(definition, { writerIsUser: true });
|
|
115
|
+
expect(result).toMatchObject({ success: false });
|
|
116
|
+
if (result.success) throw new Error("definition unexpectedly passed validation");
|
|
117
|
+
expect(result.issues).toContainEqual(issue);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
describe("json column kind — definition schema", () => {
|
|
121
|
+
test("accepts kind: json with required and object default", async () => {
|
|
122
|
+
const result = await parseAppDefinition(
|
|
123
|
+
defWith({
|
|
124
|
+
note: {
|
|
125
|
+
columns: {
|
|
126
|
+
title: { kind: "string", required: true },
|
|
127
|
+
payload: { kind: "json", required: true, default: { tags: [], pinned: false } },
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
}),
|
|
131
|
+
{ writerIsUser: true },
|
|
132
|
+
);
|
|
133
|
+
expect(result.success).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("still rejects object defaults on scalar columns", async () => {
|
|
137
|
+
await expectDefinitionIssue(
|
|
138
|
+
defWith({ note: { columns: { title: { kind: "string", default: { x: 1 } } } } }),
|
|
139
|
+
{
|
|
140
|
+
path: "models.note.columns.title.default",
|
|
141
|
+
message: "default must be a valid string value",
|
|
142
|
+
},
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("json default: exactly 256KB serialized passes, one byte more fails", async () => {
|
|
147
|
+
const exact = jsonOfSerializedBytes(DEFAULT_JSON_COLUMN_MAX_BYTES);
|
|
148
|
+
expect(Buffer.byteLength(JSON.stringify(exact), "utf8")).toBe(DEFAULT_JSON_COLUMN_MAX_BYTES);
|
|
149
|
+
const accepted = await parseAppDefinition(
|
|
150
|
+
defWith({ note: { columns: { payload: { kind: "json", default: exact } } } }),
|
|
151
|
+
{ writerIsUser: true },
|
|
152
|
+
);
|
|
153
|
+
expect(accepted.success).toBe(true);
|
|
154
|
+
|
|
155
|
+
await expectDefinitionIssue(
|
|
156
|
+
defWith({
|
|
157
|
+
note: {
|
|
158
|
+
columns: {
|
|
159
|
+
payload: {
|
|
160
|
+
kind: "json",
|
|
161
|
+
default: jsonOfSerializedBytes(DEFAULT_JSON_COLUMN_MAX_BYTES + 1),
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
}),
|
|
166
|
+
{
|
|
167
|
+
path: "models.note.columns.payload.default",
|
|
168
|
+
message: "json column default exceeds the 256KB size limit",
|
|
169
|
+
},
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("rejects index: true on a json column", async () => {
|
|
174
|
+
await expectDefinitionIssue(
|
|
175
|
+
defWith({ note: { columns: { payload: { kind: "json", index: true } } } }),
|
|
176
|
+
{ path: "models.note.columns.payload.index", message: "json columns cannot be indexed" },
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("rejects enum values on a json column", async () => {
|
|
181
|
+
await expectDefinitionIssue(
|
|
182
|
+
defWith({ note: { columns: { payload: { kind: "json", enum: ["a"] } } } }),
|
|
183
|
+
{
|
|
184
|
+
path: "models.note.columns.payload.enum",
|
|
185
|
+
message: "enum values are only allowed for enum columns",
|
|
186
|
+
},
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("rejects a named-query filter on a json column", async () => {
|
|
191
|
+
await expectDefinitionIssue(
|
|
192
|
+
{
|
|
193
|
+
...defWith({ note: { columns: { payload: { kind: "json" } } } }),
|
|
194
|
+
queries: { bad: { model: "note", filter: { payload: "x" } } },
|
|
195
|
+
},
|
|
196
|
+
{ path: "queries.bad.filter.payload", message: "json columns cannot be filtered" },
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("rejects a named-query sort on a json column", async () => {
|
|
201
|
+
await expectDefinitionIssue(
|
|
202
|
+
{
|
|
203
|
+
...defWith({ note: { columns: { payload: { kind: "json" } } } }),
|
|
204
|
+
queries: { bad: { model: "note", sort: { column: "payload", dir: "asc" } } },
|
|
205
|
+
},
|
|
206
|
+
{ path: "queries.bad.sort.column", message: "json columns cannot be sorted" },
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("rejects json kind in user config fields", async () => {
|
|
211
|
+
await expectDefinitionIssue(
|
|
212
|
+
{
|
|
213
|
+
...defWith({ note: { columns: { payload: { kind: "json" } } } }),
|
|
214
|
+
userConfig: { pref: { kind: "json" } },
|
|
215
|
+
},
|
|
216
|
+
{ path: "userConfig.pref.kind", message: expect.stringContaining("Invalid option") },
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("rejects json kind in element props", async () => {
|
|
221
|
+
await expectDefinitionIssue(
|
|
222
|
+
{
|
|
223
|
+
...defWith({ note: { columns: { payload: { kind: "json" } } } }),
|
|
224
|
+
elements: {
|
|
225
|
+
card: {
|
|
226
|
+
mode: "pure",
|
|
227
|
+
props: { payload: { kind: "json" } },
|
|
228
|
+
root: "root",
|
|
229
|
+
elements: { root: { type: "Container", props: {} } },
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
path: "elements.card.props.payload.kind",
|
|
235
|
+
message: expect.stringContaining("Invalid option"),
|
|
236
|
+
},
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
async function createNoteApp(
|
|
242
|
+
columns: Record<string, unknown> = NOTE_COLUMNS,
|
|
243
|
+
): Promise<{ appId: string; model: ModelDef }> {
|
|
244
|
+
const parsed = await parseAppDefinition(defWith({ note: { columns } }), {
|
|
245
|
+
writerIsUser: true,
|
|
246
|
+
});
|
|
247
|
+
if (!parsed.success) {
|
|
248
|
+
throw new Error(`test definition failed validation: ${JSON.stringify(parsed.issues)}`);
|
|
249
|
+
}
|
|
250
|
+
const app = await createApp({
|
|
251
|
+
name: `Json notes ${crypto.randomUUID()}`,
|
|
252
|
+
definition: parsed.definition,
|
|
253
|
+
});
|
|
254
|
+
const model = parsed.definition.models.note;
|
|
255
|
+
if (!model) throw new Error("note model missing from parsed definition");
|
|
256
|
+
return { appId: app.id, model };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function expectRowValidationError(promise: Promise<unknown>): Promise<AppRowValidationError> {
|
|
260
|
+
let error: unknown;
|
|
261
|
+
try {
|
|
262
|
+
await promise;
|
|
263
|
+
} catch (caught) {
|
|
264
|
+
error = caught;
|
|
265
|
+
}
|
|
266
|
+
if (!(error instanceof AppRowValidationError)) {
|
|
267
|
+
throw new Error("expected the row write to reject with AppRowValidationError");
|
|
268
|
+
}
|
|
269
|
+
return error;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
describe("json column kind — row mutations", () => {
|
|
273
|
+
test("createAppRow accepts a nested object and returns it intact", async () => {
|
|
274
|
+
const { appId, model } = await createNoteApp();
|
|
275
|
+
const payload = { tags: ["a", "b"], meta: { depth: { n: 1, ok: true, none: null } } };
|
|
276
|
+
const created = await createAppRow(appId, "note", model, { title: "n1", payload });
|
|
277
|
+
const fetched = await getAppRow(appId, "note", created.id);
|
|
278
|
+
if (!fetched) throw new Error("row missing after create");
|
|
279
|
+
expect(fetched.payload).toEqual(payload);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("a top-level array and scalars are valid json values", async () => {
|
|
283
|
+
const { appId, model } = await createNoteApp();
|
|
284
|
+
for (const payload of [[1, "two", { three: null }], 42, "text", false]) {
|
|
285
|
+
const created = await createAppRow(appId, "note", model, { title: "n", payload });
|
|
286
|
+
expect((await getAppRow(appId, "note", created.id))?.payload).toEqual(payload);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("null is accepted on a non-required json column", async () => {
|
|
291
|
+
const { appId, model } = await createNoteApp({
|
|
292
|
+
title: { kind: "string", required: true },
|
|
293
|
+
payload: { kind: "json" },
|
|
294
|
+
});
|
|
295
|
+
// Same contract as every other kind: an explicit null is stored, an omitted column is absent.
|
|
296
|
+
const created = await createAppRow(appId, "note", model, { title: "n", payload: null });
|
|
297
|
+
expect((await getAppRow(appId, "note", created.id))?.payload).toBeNull();
|
|
298
|
+
const omitted = await createAppRow(appId, "note", model, { title: "n" });
|
|
299
|
+
expect((await getAppRow(appId, "note", omitted.id))?.payload).toBeUndefined();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("patchAppRow replaces a json value and can clear a non-required one", async () => {
|
|
303
|
+
const { appId, model } = await createNoteApp({
|
|
304
|
+
title: { kind: "string", required: true },
|
|
305
|
+
payload: { kind: "json" },
|
|
306
|
+
});
|
|
307
|
+
const created = await createAppRow(appId, "note", model, { title: "n", payload: { v: 1 } });
|
|
308
|
+
await patchAppRow(appId, "note", model, created.id, { payload: [{ v: 2 }] });
|
|
309
|
+
expect((await getAppRow(appId, "note", created.id))?.payload).toEqual([{ v: 2 }]);
|
|
310
|
+
await patchAppRow(appId, "note", model, created.id, { payload: null });
|
|
311
|
+
expect((await getAppRow(appId, "note", created.id))?.payload).toBeUndefined();
|
|
312
|
+
const error = await expectRowValidationError(
|
|
313
|
+
patchAppRow(appId, "note", model, created.id, {
|
|
314
|
+
payload: jsonOfSerializedBytes(DEFAULT_JSON_COLUMN_MAX_BYTES + 1),
|
|
315
|
+
}),
|
|
316
|
+
);
|
|
317
|
+
expect(error.issues).toContainEqual({
|
|
318
|
+
path: "values.payload",
|
|
319
|
+
message: "json value exceeds the 256KB size limit",
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("rejects non-JSON-serializable values", async () => {
|
|
324
|
+
const { appId, model } = await createNoteApp();
|
|
325
|
+
const circular: Record<string, unknown> = {};
|
|
326
|
+
circular.self = circular;
|
|
327
|
+
const error = await expectRowValidationError(
|
|
328
|
+
createAppRow(appId, "note", model, { title: "n1", payload: circular }),
|
|
329
|
+
);
|
|
330
|
+
expect(error.issues).toContainEqual({
|
|
331
|
+
path: "values.payload",
|
|
332
|
+
message: "must be a valid json value",
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test("row value: exactly 256KB serialized passes, one byte more fails", async () => {
|
|
337
|
+
const { appId, model } = await createNoteApp();
|
|
338
|
+
const exact = jsonOfSerializedBytes(DEFAULT_JSON_COLUMN_MAX_BYTES);
|
|
339
|
+
const created = await createAppRow(appId, "note", model, { title: "n1", payload: exact });
|
|
340
|
+
expect((await getAppRow(appId, "note", created.id))?.payload).toEqual(exact);
|
|
341
|
+
|
|
342
|
+
const error = await expectRowValidationError(
|
|
343
|
+
createAppRow(appId, "note", model, {
|
|
344
|
+
title: "n1",
|
|
345
|
+
payload: jsonOfSerializedBytes(DEFAULT_JSON_COLUMN_MAX_BYTES + 1),
|
|
346
|
+
}),
|
|
347
|
+
);
|
|
348
|
+
expect(error.issues).toContainEqual({
|
|
349
|
+
path: "values.payload",
|
|
350
|
+
message: "json value exceeds the 256KB size limit",
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("the cap counts UTF-8 bytes, not characters", async () => {
|
|
355
|
+
const { appId, model } = await createNoteApp();
|
|
356
|
+
// "é" is one character but two UTF-8 bytes: fewer characters than the cap, more bytes.
|
|
357
|
+
const payload = { s: "é".repeat(DEFAULT_JSON_COLUMN_MAX_BYTES / 2 - 3) };
|
|
358
|
+
const serialized = JSON.stringify(payload);
|
|
359
|
+
expect(serialized.length).toBeLessThan(DEFAULT_JSON_COLUMN_MAX_BYTES);
|
|
360
|
+
expect(Buffer.byteLength(serialized, "utf8")).toBe(DEFAULT_JSON_COLUMN_MAX_BYTES + 2);
|
|
361
|
+
const error = await expectRowValidationError(
|
|
362
|
+
createAppRow(appId, "note", model, { title: "n1", payload }),
|
|
363
|
+
);
|
|
364
|
+
expect(error.issues).toContainEqual({
|
|
365
|
+
path: "values.payload",
|
|
366
|
+
message: "json value exceeds the 256KB size limit",
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("required json column enforced on create", async () => {
|
|
371
|
+
const { appId, model } = await createNoteApp();
|
|
372
|
+
const error = await expectRowValidationError(
|
|
373
|
+
createAppRow(appId, "note", model, { title: "n1" }),
|
|
374
|
+
);
|
|
375
|
+
expect(error.issues).toContainEqual({
|
|
376
|
+
path: "values.payload",
|
|
377
|
+
message: "required column is missing",
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
describe("json column kind — shared row selector", () => {
|
|
383
|
+
// The REST route and the MCP tools each reject json filters before reaching
|
|
384
|
+
// the selector; this is the backstop for any caller that skips its own guard.
|
|
385
|
+
test("selectAppRows rejects a filter on a json column", async () => {
|
|
386
|
+
const { appId, model } = await createNoteApp();
|
|
387
|
+
await createAppRow(appId, "note", model, { title: "scalar", payload: "x" });
|
|
388
|
+
|
|
389
|
+
const error = await expectRowValidationError(
|
|
390
|
+
selectAppRows(appId, "note", model, [{ column: "payload", value: "x" }]),
|
|
391
|
+
);
|
|
392
|
+
expect(error.issues).toEqual([
|
|
393
|
+
{ path: "filter.payload", message: "json columns cannot be filtered" },
|
|
394
|
+
]);
|
|
395
|
+
|
|
396
|
+
const unfiltered = await selectAppRows(appId, "note", model, []);
|
|
397
|
+
expect(unfiltered.rows.map((row) => row.payload)).toEqual(["x"]);
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
describe("json column kind — REST runtime", () => {
|
|
402
|
+
async function createNoteAppViaApi(): Promise<string> {
|
|
403
|
+
const result = await request<{ app: { id: string } }>("/api/apps", {
|
|
404
|
+
method: "POST",
|
|
405
|
+
body: JSON.stringify({
|
|
406
|
+
name: "Json notes",
|
|
407
|
+
definition: defWith({ note: { columns: NOTE_COLUMNS } }),
|
|
408
|
+
}),
|
|
409
|
+
});
|
|
410
|
+
expect(result.status).toBe(201);
|
|
411
|
+
return result.body.app.id;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
test("filter.<col> on a json column is rejected", async () => {
|
|
415
|
+
const appId = await createNoteAppViaApi();
|
|
416
|
+
const result = await request<{ error: string; issues: unknown[] }>(
|
|
417
|
+
`/api/apps/${appId}/models/note/rows?filter.payload=x`,
|
|
418
|
+
);
|
|
419
|
+
expect(result.status).toBe(400);
|
|
420
|
+
expect(result.body.error).toBe("invalid row query");
|
|
421
|
+
expect(result.body.issues).toContainEqual({
|
|
422
|
+
path: "filter.payload",
|
|
423
|
+
message: "json columns cannot be filtered",
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test("sort on a json column is rejected", async () => {
|
|
428
|
+
const appId = await createNoteAppViaApi();
|
|
429
|
+
const result = await request<{ error: string; issues: unknown[] }>(
|
|
430
|
+
`/api/apps/${appId}/models/note/rows?sort=payload:asc`,
|
|
431
|
+
);
|
|
432
|
+
expect(result.status).toBe(400);
|
|
433
|
+
expect(result.body).toEqual({
|
|
434
|
+
error: "invalid sort",
|
|
435
|
+
issues: [{ path: "sort", message: "json columns cannot be sorted" }],
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
// Bodies are sent as raw text: `JSON.parse("1e400")` is `Infinity`, which
|
|
440
|
+
// `JSON.stringify` would otherwise persist as `null`, even on a required column.
|
|
441
|
+
test("a row value with a non-finite number is rejected, top-level and nested", async () => {
|
|
442
|
+
const appId = await createNoteAppViaApi();
|
|
443
|
+
for (const body of [
|
|
444
|
+
'{"values":{"title":"top","payload":1e400}}',
|
|
445
|
+
'{"values":{"title":"nested","payload":{"n":[1,{"deep":-1e400}]}}}',
|
|
446
|
+
]) {
|
|
447
|
+
const result = await request<{ error: string; issues: unknown[] }>(
|
|
448
|
+
`/api/apps/${appId}/models/note/rows`,
|
|
449
|
+
{ method: "POST", body },
|
|
450
|
+
);
|
|
451
|
+
expect(result.status).toBe(400);
|
|
452
|
+
expect(result.body.issues).toContainEqual({
|
|
453
|
+
path: "values.payload",
|
|
454
|
+
message: "json value contains a non-finite number",
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
const listed = await request<{ rows: unknown[] }>(`/api/apps/${appId}/models/note/rows`);
|
|
458
|
+
expect(listed.status).toBe(200);
|
|
459
|
+
expect(listed.body.rows).toEqual([]);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test("a json default with a non-finite number is rejected", async () => {
|
|
463
|
+
const definition = JSON.stringify(
|
|
464
|
+
defWith({ note: { columns: { payload: { kind: "json", default: { n: "__NF__" } } } } }),
|
|
465
|
+
).replace('"__NF__"', "1e400");
|
|
466
|
+
const result = await request<{ error: string; issues: unknown[] }>("/api/apps", {
|
|
467
|
+
method: "POST",
|
|
468
|
+
body: `{"name":"Json defaults","definition":${definition}}`,
|
|
469
|
+
});
|
|
470
|
+
expect(result.status).toBe(400);
|
|
471
|
+
expect(result.body.issues).toContainEqual({
|
|
472
|
+
path: "models.note.columns.payload.default",
|
|
473
|
+
message: "json column default contains a non-finite number",
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
test("migration.<col>.set backfills an object into a new json column", async () => {
|
|
478
|
+
const appId = await createNoteAppViaApi();
|
|
479
|
+
const app = await getApp(appId);
|
|
480
|
+
const model = app?.definition.models.note;
|
|
481
|
+
if (!model) throw new Error("note model missing");
|
|
482
|
+
const row = await createAppRow(appId, "note", model, { title: "n1", payload: { v: 1 } });
|
|
483
|
+
|
|
484
|
+
const meta = { tags: ["x"], nested: { n: 1 } };
|
|
485
|
+
const applied = await request<{ migration: { backfilled: number } }>(`/api/apps/${appId}`, {
|
|
486
|
+
method: "PATCH",
|
|
487
|
+
body: JSON.stringify({
|
|
488
|
+
definition: { models: { note: { columns: { meta: { kind: "json", required: true } } } } },
|
|
489
|
+
migration: { meta: { set: meta } },
|
|
490
|
+
}),
|
|
491
|
+
});
|
|
492
|
+
expect(applied).toMatchObject({ status: 200, body: { migration: { backfilled: 1 } } });
|
|
493
|
+
expect((await getAppRow(appId, "note", row.id))?.meta).toEqual(meta);
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
describe("json column kind — configurable size limit", () => {
|
|
498
|
+
const original = process.env.APPS_JSON_COLUMN_MAX_BYTES;
|
|
499
|
+
afterEach(() => {
|
|
500
|
+
if (original === undefined) delete process.env.APPS_JSON_COLUMN_MAX_BYTES;
|
|
501
|
+
else process.env.APPS_JSON_COLUMN_MAX_BYTES = original;
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test("the default cap is 256KB and labels its messages as such", () => {
|
|
505
|
+
expect(DEFAULT_JSON_COLUMN_MAX_BYTES).toBe(256 * 1024);
|
|
506
|
+
expect(jsonColumnMaxBytes()).toBe(DEFAULT_JSON_COLUMN_MAX_BYTES);
|
|
507
|
+
expect(jsonColumnSizeLabel(DEFAULT_JSON_COLUMN_MAX_BYTES)).toBe("256KB");
|
|
508
|
+
expect(jsonColumnSizeLabel(1500)).toBe("1500 bytes");
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test("APPS_JSON_COLUMN_MAX_BYTES is read at call time and relabels the limit", async () => {
|
|
512
|
+
const { appId, model } = await createNoteApp();
|
|
513
|
+
process.env.APPS_JSON_COLUMN_MAX_BYTES = "2048";
|
|
514
|
+
expect(jsonColumnMaxBytes()).toBe(2048);
|
|
515
|
+
|
|
516
|
+
const error = await expectRowValidationError(
|
|
517
|
+
createAppRow(appId, "note", model, {
|
|
518
|
+
title: "big",
|
|
519
|
+
payload: jsonOfSerializedBytes(3 * 1024),
|
|
520
|
+
}),
|
|
521
|
+
);
|
|
522
|
+
expect(error.issues).toContainEqual({
|
|
523
|
+
path: "values.payload",
|
|
524
|
+
message: "json value exceeds the 2KB size limit",
|
|
525
|
+
});
|
|
526
|
+
const small = jsonOfSerializedBytes(1024);
|
|
527
|
+
const row = await createAppRow(appId, "note", model, { title: "small", payload: small });
|
|
528
|
+
expect((await getAppRow(appId, "note", row.id))?.payload).toEqual(small);
|
|
529
|
+
|
|
530
|
+
await expectDefinitionIssue(
|
|
531
|
+
defWith({
|
|
532
|
+
note: { columns: { payload: { kind: "json", default: jsonOfSerializedBytes(3 * 1024) } } },
|
|
533
|
+
}),
|
|
534
|
+
{
|
|
535
|
+
path: "models.note.columns.payload.default",
|
|
536
|
+
message: "json column default exceeds the 2KB size limit",
|
|
537
|
+
},
|
|
538
|
+
);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
// The cap is an upsert-time constraint: lowering it must not turn an app
|
|
542
|
+
// saved under the higher cap into one that "needs repair" on every read.
|
|
543
|
+
test("lowering the cap keeps an app saved under the higher cap readable", async () => {
|
|
544
|
+
process.env.APPS_JSON_COLUMN_MAX_BYTES = "4096";
|
|
545
|
+
const definition = defWith({
|
|
546
|
+
note: {
|
|
547
|
+
columns: {
|
|
548
|
+
title: { kind: "string", required: true },
|
|
549
|
+
payload: { kind: "json", required: true, default: jsonOfSerializedBytes(2048) },
|
|
550
|
+
},
|
|
551
|
+
},
|
|
552
|
+
});
|
|
553
|
+
const created = await request<{ app: { id: string } }>("/api/apps", {
|
|
554
|
+
method: "POST",
|
|
555
|
+
body: JSON.stringify({ name: "Json cap", definition }),
|
|
556
|
+
});
|
|
557
|
+
expect(created.status).toBe(201);
|
|
558
|
+
const appId = created.body.app.id;
|
|
559
|
+
const stored = { s: "y".repeat(2048 - 8) };
|
|
560
|
+
const createdRow = await request<{ row: { id: string } }>(
|
|
561
|
+
`/api/apps/${appId}/models/note/rows`,
|
|
562
|
+
{ method: "POST", body: JSON.stringify({ values: { title: "big", payload: stored } }) },
|
|
563
|
+
);
|
|
564
|
+
expect(createdRow.status).toBe(201);
|
|
565
|
+
|
|
566
|
+
process.env.APPS_JSON_COLUMN_MAX_BYTES = "1024";
|
|
567
|
+
const app = await request<{ app: { definitionError?: unknown } }>(`/api/apps/${appId}`);
|
|
568
|
+
expect(app.status).toBe(200);
|
|
569
|
+
expect(app.body.app.definitionError).toBeUndefined();
|
|
570
|
+
const rows = await request<{ rows: Array<{ payload: unknown }> }>(
|
|
571
|
+
`/api/apps/${appId}/models/note/rows`,
|
|
572
|
+
);
|
|
573
|
+
expect(rows.status).toBe(200);
|
|
574
|
+
expect(rows.body.rows.map((row) => row.payload)).toEqual([stored]);
|
|
575
|
+
expect((await getAppRow(appId, "note", createdRow.body.row.id))?.payload).toEqual(stored);
|
|
576
|
+
|
|
577
|
+
// Writing the same definition again under the lower cap is still refused.
|
|
578
|
+
const rewritten = await request<{ error: string; issues: unknown[] }>(`/api/apps/${appId}`, {
|
|
579
|
+
method: "PUT",
|
|
580
|
+
body: JSON.stringify({ definition }),
|
|
581
|
+
});
|
|
582
|
+
expect(rewritten.status).toBe(400);
|
|
583
|
+
expect(rewritten.body.issues).toContainEqual({
|
|
584
|
+
path: "models.note.columns.payload.default",
|
|
585
|
+
message: "json column default exceeds the 1KB size limit",
|
|
586
|
+
});
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
test("a malformed or out-of-range override falls back to the default", () => {
|
|
590
|
+
const { min, max } = APPS_JSON_COLUMN_MAX_BYTES_BOUNDS;
|
|
591
|
+
for (const raw of ["", "abc", "2048.5", "-2048", String(min - 1), String(max + 1)]) {
|
|
592
|
+
process.env.APPS_JSON_COLUMN_MAX_BYTES = raw;
|
|
593
|
+
expect(jsonColumnMaxBytes()).toBe(DEFAULT_JSON_COLUMN_MAX_BYTES);
|
|
594
|
+
}
|
|
595
|
+
for (const raw of [String(min), String(max)]) {
|
|
596
|
+
process.env.APPS_JSON_COLUMN_MAX_BYTES = raw;
|
|
597
|
+
expect(jsonColumnMaxBytes()).toBe(Number(raw));
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
test("the config API rejects what the reader would fall back from", () => {
|
|
602
|
+
const { min, max } = APPS_JSON_COLUMN_MAX_BYTES_BOUNDS;
|
|
603
|
+
const key = "APPS_JSON_COLUMN_MAX_BYTES";
|
|
604
|
+
expect(validateConfigValue(key, String(min))).toBeNull();
|
|
605
|
+
expect(validateConfigValue(key, String(max))).toBeNull();
|
|
606
|
+
expect(validateConfigValue(key, String(min - 1))).toContain(`between ${min} and ${max}`);
|
|
607
|
+
expect(validateConfigValue(key, String(max + 1))).toContain(`between ${min} and ${max}`);
|
|
608
|
+
expect(validateConfigValue(key, "abc")).toContain("integer");
|
|
609
|
+
expect(validateConfigValue(key, "1.5")).toContain("integer");
|
|
610
|
+
});
|
|
611
|
+
});
|
|
@@ -42,6 +42,7 @@ function issueDefinition() {
|
|
|
42
42
|
enabled: { kind: "boolean" },
|
|
43
43
|
dueAt: { kind: "date" },
|
|
44
44
|
status: { kind: "enum", enum: ["open", "urgent"] },
|
|
45
|
+
payload: { kind: "json" },
|
|
45
46
|
hidden: { kind: "string", hidden: true },
|
|
46
47
|
},
|
|
47
48
|
},
|
|
@@ -70,6 +71,7 @@ describe("renderAppTypes", () => {
|
|
|
70
71
|
expect(rendered).toContain("enabled?: boolean;");
|
|
71
72
|
expect(rendered).toContain("/** date */\n dueAt?: string;");
|
|
72
73
|
expect(rendered).toContain('status?: "open" | "urgent";');
|
|
74
|
+
expect(rendered).toContain("payload?: unknown;");
|
|
73
75
|
expect(rendered).not.toContain("hidden?:");
|
|
74
76
|
expect(rendered).toContain("id: string;");
|
|
75
77
|
expect(rendered).toContain("createdAt: string;");
|
|
@@ -994,6 +994,48 @@ describe("swarm-tasks source", () => {
|
|
|
994
994
|
refreshSecretScrubberCache();
|
|
995
995
|
}
|
|
996
996
|
});
|
|
997
|
+
|
|
998
|
+
test("projects an array field into a json column intact", async () => {
|
|
999
|
+
const task = await createTaskExtended("tagged", {
|
|
1000
|
+
agentId: OWNER_AGENT_ID,
|
|
1001
|
+
tags: ["alpha", "beta"],
|
|
1002
|
+
});
|
|
1003
|
+
const appId = await createSyncApp(
|
|
1004
|
+
appWith({
|
|
1005
|
+
task: {
|
|
1006
|
+
columns: {
|
|
1007
|
+
...TASK_COLUMNS,
|
|
1008
|
+
taskTags: { kind: "json", source: { of: "pool", field: "tags" } },
|
|
1009
|
+
},
|
|
1010
|
+
sources: { pool: { connector: "swarm-tasks", joinKey: "taskKey", config: {} } },
|
|
1011
|
+
},
|
|
1012
|
+
}),
|
|
1013
|
+
);
|
|
1014
|
+
|
|
1015
|
+
const pass = (await runAppSync({ appId })).passes[0]!;
|
|
1016
|
+
|
|
1017
|
+
expect(pass).toMatchObject({ connector: "swarm-tasks", pulled: 1, created: 1 });
|
|
1018
|
+
const rows = await rowsOf(appId, "task", "taskKey");
|
|
1019
|
+
expect(rows).toHaveLength(1);
|
|
1020
|
+
expect(rows[0]).toMatchObject({ taskKey: task.id, taskTags: ["alpha", "beta"] });
|
|
1021
|
+
|
|
1022
|
+
// A json projection is compared structurally: the decoded stored value and
|
|
1023
|
+
// a fresh identical projection are a refresh, not an update.
|
|
1024
|
+
const second = (await runAppSync({ appId })).passes[0]!;
|
|
1025
|
+
expect(second).toMatchObject({ pulled: 1, created: 0, updated: 0, refreshed: 1 });
|
|
1026
|
+
const refreshed = (await rowsOf(appId, "task", "taskKey"))[0]!;
|
|
1027
|
+
expect(refreshed.updatedAt).toBe(rows[0]!.updatedAt);
|
|
1028
|
+
|
|
1029
|
+
await getDbClient().run("UPDATE agent_tasks SET tags = ? WHERE id = ?", [
|
|
1030
|
+
JSON.stringify(["alpha", "gamma"]),
|
|
1031
|
+
task.id,
|
|
1032
|
+
]);
|
|
1033
|
+
const third = (await runAppSync({ appId })).passes[0]!;
|
|
1034
|
+
expect(third).toMatchObject({ pulled: 1, created: 0, updated: 1, refreshed: 0 });
|
|
1035
|
+
expect((await rowsOf(appId, "task", "taskKey"))[0]).toMatchObject({
|
|
1036
|
+
taskTags: ["alpha", "gamma"],
|
|
1037
|
+
});
|
|
1038
|
+
});
|
|
997
1039
|
});
|
|
998
1040
|
|
|
999
1041
|
describe("pair expansion", () => {
|