@desplega.ai/agent-swarm 2.4.1 → 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/dist/{acp-adapter-zvzr538d.js → acp-adapter-dv7hmx4r.js} +1 -1
- package/dist/{actions-jhtqdw69.js → actions-51nc1gny.js} +16 -16
- package/dist/{amp-adapter-bd84rhs2.js → amp-adapter-abp5tdx8.js} +1 -1
- package/dist/{app-kreq1gwg.js → app-9h14qvr6.js} +3 -3
- package/dist/{assistant-3k22sbsq.js → assistant-wv1gz3mt.js} +17 -17
- package/dist/{boot-reembed-e53x6b9v.js → boot-reembed-6yn53ms9.js} +1 -1
- package/dist/{boot-reembed-f6z923ny.js → boot-reembed-7fcka4z5.js} +1 -1
- package/dist/{boot-scrub-sweep-0538vsb7.js → boot-scrub-sweep-pm1580ax.js} +2 -2
- package/dist/{boot-seeds-6wxqkfk2.js → boot-seeds-g5hfc1x8.js} +4 -4
- package/dist/{claude-adapter-gfts5044.js → claude-adapter-gf3kxhf0.js} +2 -2
- package/dist/{claude-sdk-session-56besmfz.js → claude-sdk-session-s2k1qd8y.js} +2 -2
- package/dist/{cli-tbhwrxpt.js → cli-02qm7src.js} +3 -3
- package/dist/{cli-3kh4kpqy.js → cli-0qvtffe9.js} +5 -5
- package/dist/{cli-p6argxh9.js → cli-10hhg0km.js} +6 -6
- package/dist/{cli-gf0kfg81.js → cli-239yjk27.js} +2 -2
- package/dist/{cli-kr2pmfqx.js → cli-5rk36sjf.js} +72 -59
- package/dist/{cli-nx462e4v.js → cli-7awf1fp8.js} +1 -1
- package/dist/{cli-jh0qcrga.js → cli-8sxqsbbq.js} +1 -1
- package/dist/{cli-axh29tyr.js → cli-8zwe9mpk.js} +5 -5
- package/dist/{cli-z1ew8vbq.js → cli-ahtpy9kf.js} +1 -1
- package/dist/{cli-3r448dqn.js → cli-bcj753sq.js} +3 -3
- package/dist/{cli-rqqsfz30.js → cli-d3qqdy4c.js} +82 -12
- package/dist/{cli-z071fb95.js → cli-efaqv1c7.js} +4 -4
- package/dist/{cli-s48yvzb8.js → cli-faypxz7q.js} +3 -3
- package/dist/{cli-m2qgh5sv.js → cli-ftyypbg5.js} +1 -1
- package/dist/{cli-6ncjvx55.js → cli-fyzjmseb.js} +1 -1
- package/dist/{cli-0bh477zn.js → cli-h38rjv87.js} +117 -9
- package/dist/{cli-3p8fqcnh.js → cli-h93c9vb3.js} +1 -1
- package/dist/{cli-rdq7tzwm.js → cli-hyfw2j1e.js} +8 -8
- package/dist/{cli-bn5kevnf.js → cli-n85dqg6d.js} +2 -2
- package/dist/{cli-fz9kewbh.js → cli-nmqdc2t3.js} +1 -1
- package/dist/{cli-vy0fay6k.js → cli-nn47d7e8.js} +10 -10
- package/dist/{cli-mfe7f7b5.js → cli-pkht5hap.js} +1 -1
- package/dist/{cli-xftrvxag.js → cli-r99kc9yq.js} +1 -1
- package/dist/{cli-p0rakbrk.js → cli-rdyw3mct.js} +1 -1
- package/dist/{cli-sjyetj21.js → cli-s6vr7r2d.js} +1 -1
- package/dist/{cli-k9z4zd4p.js → cli-saw9a6f6.js} +2 -2
- package/dist/{cli-ash6xxk7.js → cli-shvfedvm.js} +2 -2
- package/dist/{cli-syhq378v.js → cli-t30yj95q.js} +1 -1
- package/dist/{cli-kmqgmcnb.js → cli-y2x52gs2.js} +4 -4
- package/dist/{cli-471qk6s2.js → cli-y8n5k515.js} +14 -14
- package/dist/{cli-3tnpj2bh.js → cli-z4wd2ftv.js} +1 -1
- package/dist/cli.js +12 -12
- package/dist/{codex-adapter-p6rmm7vv.js → codex-adapter-ttygw382.js} +1 -1
- package/dist/{codex-session-runner-8vd5t9t0.js → codex-session-runner-60b58vv3.js} +1 -1
- package/dist/{commands-7ems2p13.js → commands-edmdwa4x.js} +2 -2
- package/dist/{controllers-connect-bg33ae53.js → controllers-connect-5fym8sw1.js} +16 -16
- package/dist/{db-ys2rbdft.js → db-2y0mgstc.js} +3 -3
- package/dist/{db-w505dwa8.js → db-nn0hcmx5.js} +5 -1
- package/dist/{dispatcher-0s0mckqr.js → dispatcher-0a9wwjcx.js} +15 -15
- package/dist/{external-channel-5b23fv8z.js → external-channel-zhvt95n3.js} +2 -2
- package/dist/{grok-adapter-q9fghe5q.js → grok-adapter-qnr1hp2y.js} +2 -2
- package/dist/{handlers-mqrym347.js → handlers-dkcarkd0.js} +18 -18
- package/dist/{heartbeat-tww6yx1w.js → heartbeat-gz97r7ac.js} +17 -17
- package/dist/{hook-dx0pkvar.js → hook-1mmdgjqs.js} +1 -1
- package/dist/{hook-pvmh9b6m.js → hook-x8rthca4.js} +1 -1
- package/dist/{http-k2ejbt05.js → http-r8f85xr9.js} +111 -65
- package/dist/{index-pqfwh9nn.js → index-5jecgp3n.js} +17 -17
- package/dist/{index-h2ek08d6.js → index-aex0eba9.js} +15 -15
- package/dist/{index-x5aktmp8.js → index-smxyk04s.js} +1 -1
- package/dist/{index-dgz61nff.js → index-vzvfqzfj.js} +15 -15
- package/dist/{index-2z710v4x.js → index-y7rbcrc1.js} +8 -8
- package/dist/{keepalive-nhvpw10z.js → keepalive-64c0f34c.js} +3 -3
- package/dist/{lead-jb6sf69z.js → lead-b1da0f97.js} +23 -23
- package/dist/{maintenance-agakc519.js → maintenance-mnxpar12.js} +1 -1
- package/dist/{oauth-refresh-sweep-4dcbhtn0.js → oauth-refresh-sweep-36hs2v4h.js} +2 -2
- package/dist/{onboard-4n2kwa4v.js → onboard-xes8de61.js} +2 -2
- package/dist/{otel-impl-tfa12080.js → otel-impl-fzc57kcq.js} +1 -1
- package/dist/{pricing-refresh-3cqx3f3x.js → pricing-refresh-1nek2w7b.js} +3 -3
- package/dist/{rbac-roles-y6zbfzve.js → rbac-roles-sqefb5hw.js} +2 -2
- package/dist/{rbac-roles-9fae5rnv.js → rbac-roles-ttyfaep2.js} +1 -1
- package/dist/{render-v2-emawjvc4.js → render-v2-vb527jwn.js} +3 -3
- package/dist/{resume-zhgts7h7.js → resume-jcdzg1bn.js} +5 -5
- package/dist/{session-access-cm823e06.js → session-access-zsp4wsp0.js} +4 -4
- package/dist/{setup-tkp99w1e.js → setup-1nb2zqd3.js} +1 -1
- package/dist/{work-objects-f722h263.js → work-objects-61w08fwr.js} +18 -18
- package/dist/{worker-p7f336rq.js → worker-c1e68hpa.js} +23 -23
- package/openapi.json +393 -1
- package/package.json +2 -2
- package/src/apps/catalog.generated.json +3 -2
- package/src/apps/definition.ts +152 -4
- package/src/apps/row-store.ts +19 -7
- package/src/apps/schema-migrate.ts +14 -6
- package/src/apps/script-types.ts +1 -0
- package/src/apps/sync.ts +20 -19
- package/src/be/db/tasks/read.ts +112 -0
- package/src/be/db.ts +2 -0
- package/src/be/swarm-config-guard.ts +15 -0
- package/src/http/apps.ts +15 -2
- package/src/http/status.ts +49 -43
- package/src/http/tasks.ts +47 -0
- package/src/providers/acp-targets.ts +1 -1
- package/src/providers/amp-adapter.ts +1 -1
- package/src/telemetry.ts +15 -3
- package/src/tests/app-row-tools.test.ts +25 -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/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/runner-harness-telemetry-env.test.ts +6 -0
- package/src/tests/status.test.ts +158 -9
- package/src/tests/task-lineage.test.ts +180 -0
- package/src/tests/telemetry-init.test.ts +62 -5
- package/src/tests/user-token-rest-auth.test.ts +38 -0
- package/src/tools/app-rows.ts +5 -0
- package/templates/skills/apps/SKILL.md +3 -2
- package/templates/skills/apps/content.md +3 -2
package/src/apps/definition.ts
CHANGED
|
@@ -2,6 +2,7 @@ import * as z from "zod";
|
|
|
2
2
|
import { listScriptConnections } from "../be/script-connections";
|
|
3
3
|
import { getScriptById } from "../be/scripts/db";
|
|
4
4
|
import { getSavedScriptOwnerAgentId } from "../be/scripts/run-saved";
|
|
5
|
+
import { APPS_JSON_COLUMN_MAX_BYTES_BOUNDS } from "../be/swarm-config-guard";
|
|
5
6
|
import { checkQuickJSCompat, quickJSCompatWarnings } from "../scripts-runtime/quickjs-compat";
|
|
6
7
|
import catalog from "./catalog.generated.json";
|
|
7
8
|
import {
|
|
@@ -16,7 +17,13 @@ export const AppNameSchema = z.string().regex(/^[a-z][a-zA-Z0-9_]{0,39}$/, {
|
|
|
16
17
|
message: "must start with a lowercase letter and contain only letters, numbers, or underscores",
|
|
17
18
|
});
|
|
18
19
|
|
|
19
|
-
export const ColumnKindSchema = z.enum(["string", "number", "boolean", "date", "enum"]);
|
|
20
|
+
export const ColumnKindSchema = z.enum(["string", "number", "boolean", "date", "enum", "json"]);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Column kinds allowed where values must stay scalar (user-config fields and
|
|
24
|
+
* element props) — `json` is model-column only.
|
|
25
|
+
*/
|
|
26
|
+
export const ScalarColumnKindSchema = ColumnKindSchema.exclude(["json"]);
|
|
20
27
|
|
|
21
28
|
const ISO_8601_PREFIX = /^\d{4}-\d{2}-\d{2}(?:T.*)?$/;
|
|
22
29
|
|
|
@@ -24,6 +31,77 @@ export function isIso8601Date(value: string): boolean {
|
|
|
24
31
|
return ISO_8601_PREFIX.test(value) && !Number.isNaN(Date.parse(value));
|
|
25
32
|
}
|
|
26
33
|
|
|
34
|
+
/** Default serialized-size cap for json column values AND defaults: 256 KB. */
|
|
35
|
+
export const DEFAULT_JSON_COLUMN_MAX_BYTES = 256 * 1024;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The serialized-size cap for json column values and defaults, read on every
|
|
39
|
+
* call — never at module load — so a stored `APPS_JSON_COLUMN_MAX_BYTES`
|
|
40
|
+
* override, injected over the process env on config reload, applies without
|
|
41
|
+
* a restart. Mirrors the retention knobs: a non-digit or out-of-range value
|
|
42
|
+
* falls back to the default, and the config API rejects writes outside the
|
|
43
|
+
* same `APPS_JSON_COLUMN_MAX_BYTES_BOUNDS`.
|
|
44
|
+
*/
|
|
45
|
+
export function jsonColumnMaxBytes(env: NodeJS.ProcessEnv = process.env): number {
|
|
46
|
+
const raw = env.APPS_JSON_COLUMN_MAX_BYTES?.trim();
|
|
47
|
+
if (!raw || !/^\d+$/.test(raw)) return DEFAULT_JSON_COLUMN_MAX_BYTES;
|
|
48
|
+
const value = Number(raw);
|
|
49
|
+
const { min, max } = APPS_JSON_COLUMN_MAX_BYTES_BOUNDS;
|
|
50
|
+
return Number.isSafeInteger(value) && value >= min && value <= max
|
|
51
|
+
? value
|
|
52
|
+
: DEFAULT_JSON_COLUMN_MAX_BYTES;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The cap as a message label: whole KB when it divides evenly (`256KB`), else bytes. */
|
|
56
|
+
export function jsonColumnSizeLabel(bytes: number = jsonColumnMaxBytes()): string {
|
|
57
|
+
return bytes % 1024 === 0 ? `${bytes / 1024}KB` : `${bytes} bytes`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type JsonColumnValueIssue = "unserializable" | "non-finite" | "oversized";
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Why a value cannot live in a json column — not JSON-serializable, carrying a
|
|
64
|
+
* non-finite number anywhere inside it, or over `jsonColumnMaxBytes()` once
|
|
65
|
+
* serialized — or `null` when it fits. The single implementation of the cap:
|
|
66
|
+
* it serializes exactly once, flagging non-finite numbers on that same walk
|
|
67
|
+
* (a lenient parser turns `1e400` into `Infinity`, which `JSON.stringify`
|
|
68
|
+
* would quietly store as `null`), and each caller maps the reason to its own
|
|
69
|
+
* message.
|
|
70
|
+
*/
|
|
71
|
+
export function jsonColumnValueIssue(value: unknown): JsonColumnValueIssue | null {
|
|
72
|
+
let nonFinite = false;
|
|
73
|
+
let serialized: string | undefined;
|
|
74
|
+
try {
|
|
75
|
+
serialized = JSON.stringify(value, (_key, entry: unknown) => {
|
|
76
|
+
if (typeof entry === "number" && !Number.isFinite(entry)) nonFinite = true;
|
|
77
|
+
return entry;
|
|
78
|
+
});
|
|
79
|
+
} catch {
|
|
80
|
+
return "unserializable"; // circular reference / BigInt / throwing toJSON
|
|
81
|
+
}
|
|
82
|
+
if (serialized === undefined) return "unserializable";
|
|
83
|
+
if (nonFinite) return "non-finite";
|
|
84
|
+
return Buffer.byteLength(serialized, "utf8") > jsonColumnMaxBytes() ? "oversized" : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The one per-kind check for a NON-null column value, shared by the row store,
|
|
89
|
+
* schema migrations and the sync engine so a value the store accepts is a
|
|
90
|
+
* value a migration directive or a projection accepts too. Null policy
|
|
91
|
+
* (required / hidden / nulling a projection) stays with each caller.
|
|
92
|
+
*/
|
|
93
|
+
export function isValidColumnValue(
|
|
94
|
+
column: Pick<ColumnDef, "kind" | "enum">,
|
|
95
|
+
value: unknown,
|
|
96
|
+
): boolean {
|
|
97
|
+
if (column.kind === "string") return typeof value === "string";
|
|
98
|
+
if (column.kind === "number") return typeof value === "number" && Number.isFinite(value);
|
|
99
|
+
if (column.kind === "boolean") return typeof value === "boolean";
|
|
100
|
+
if (column.kind === "date") return typeof value === "string" && isIso8601Date(value);
|
|
101
|
+
if (column.kind === "json") return jsonColumnValueIssue(value) === null;
|
|
102
|
+
return typeof value === "string" && Boolean(column.enum?.includes(value));
|
|
103
|
+
}
|
|
104
|
+
|
|
27
105
|
export const SourceTransformSchema = z.enum(["slug", "lower", "upper", "cents", "date-parse"]);
|
|
28
106
|
|
|
29
107
|
/** Column kind each transform may target — the sync engine projects into it. */
|
|
@@ -49,11 +127,23 @@ const ColumnDefSchema = z
|
|
|
49
127
|
required: z.boolean().optional(),
|
|
50
128
|
enum: z.array(z.string()).optional(),
|
|
51
129
|
index: z.boolean().optional(),
|
|
52
|
-
default: z
|
|
130
|
+
default: z
|
|
131
|
+
.union([
|
|
132
|
+
z.string(),
|
|
133
|
+
z.number(),
|
|
134
|
+
z.boolean(),
|
|
135
|
+
z.record(z.string(), z.unknown()),
|
|
136
|
+
z.array(z.unknown()),
|
|
137
|
+
])
|
|
138
|
+
.optional(),
|
|
53
139
|
hidden: z.boolean().optional(),
|
|
54
140
|
source: ColumnSourceBindingSchema.optional(),
|
|
55
141
|
})
|
|
56
142
|
.superRefine((column, ctx) => {
|
|
143
|
+
if (column.kind === "json" && column.index) {
|
|
144
|
+
ctx.addIssue({ code: "custom", path: ["index"], message: "json columns cannot be indexed" });
|
|
145
|
+
}
|
|
146
|
+
|
|
57
147
|
if (column.kind === "enum") {
|
|
58
148
|
if (!column.enum || column.enum.length === 0) {
|
|
59
149
|
ctx.addIssue({ code: "custom", path: ["enum"], message: "enum values are required" });
|
|
@@ -71,6 +161,24 @@ const ColumnDefSchema = z
|
|
|
71
161
|
}
|
|
72
162
|
|
|
73
163
|
if (column.default === undefined) return;
|
|
164
|
+
if (column.kind === "json") {
|
|
165
|
+
// Structural checks only; the size cap is write-time (`jsonColumnDefaultSizeIssues`).
|
|
166
|
+
const issue = jsonColumnValueIssue(column.default);
|
|
167
|
+
if (issue === "unserializable") {
|
|
168
|
+
ctx.addIssue({
|
|
169
|
+
code: "custom",
|
|
170
|
+
path: ["default"],
|
|
171
|
+
message: "json column default must be JSON-serializable",
|
|
172
|
+
});
|
|
173
|
+
} else if (issue === "non-finite") {
|
|
174
|
+
ctx.addIssue({
|
|
175
|
+
code: "custom",
|
|
176
|
+
path: ["default"],
|
|
177
|
+
message: "json column default contains a non-finite number",
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
74
182
|
const valid =
|
|
75
183
|
(column.kind === "string" && typeof column.default === "string") ||
|
|
76
184
|
(column.kind === "number" &&
|
|
@@ -94,7 +202,7 @@ const ColumnDefSchema = z
|
|
|
94
202
|
|
|
95
203
|
const UserConfigFieldSchema = z
|
|
96
204
|
.object({
|
|
97
|
-
kind:
|
|
205
|
+
kind: ScalarColumnKindSchema,
|
|
98
206
|
default: z.union([z.string(), z.number(), z.boolean()]).optional(),
|
|
99
207
|
enum: z.array(z.string()).optional(),
|
|
100
208
|
label: z.string().optional(),
|
|
@@ -276,7 +384,7 @@ const AppPageSchema = z
|
|
|
276
384
|
|
|
277
385
|
const ElementPropDefSchema = z
|
|
278
386
|
.object({
|
|
279
|
-
kind:
|
|
387
|
+
kind: ScalarColumnKindSchema,
|
|
280
388
|
required: z.boolean().optional(),
|
|
281
389
|
enum: z.array(z.string()).min(1).optional(),
|
|
282
390
|
default: z.union([z.string(), z.number(), z.boolean()]).optional(),
|
|
@@ -439,6 +547,14 @@ export const AppDefinitionSchema = z
|
|
|
439
547
|
});
|
|
440
548
|
continue;
|
|
441
549
|
}
|
|
550
|
+
if (columnDefinition.kind === "json") {
|
|
551
|
+
ctx.addIssue({
|
|
552
|
+
code: "custom",
|
|
553
|
+
path: ["queries", queryName, "filter", column],
|
|
554
|
+
message: "json columns cannot be filtered",
|
|
555
|
+
});
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
442
558
|
if (typeof value === "object") continue;
|
|
443
559
|
const valid =
|
|
444
560
|
(columnDefinition.kind === "string" && typeof value === "string") ||
|
|
@@ -469,6 +585,12 @@ export const AppDefinitionSchema = z
|
|
|
469
585
|
path: ["queries", queryName, "sort", "column"],
|
|
470
586
|
message: `unknown or hidden column "${sortColumn}"`,
|
|
471
587
|
});
|
|
588
|
+
} else if (sortColumn && model.columns[sortColumn]?.kind === "json") {
|
|
589
|
+
ctx.addIssue({
|
|
590
|
+
code: "custom",
|
|
591
|
+
path: ["queries", queryName, "sort", "column"],
|
|
592
|
+
message: "json columns cannot be sorted",
|
|
593
|
+
});
|
|
472
594
|
}
|
|
473
595
|
}
|
|
474
596
|
});
|
|
@@ -836,6 +958,31 @@ function syncActionIssues(
|
|
|
836
958
|
return [{ path: `actions.${name}`, message: "no model declares a source to sync" }];
|
|
837
959
|
}
|
|
838
960
|
|
|
961
|
+
/**
|
|
962
|
+
* The serialized-size cap on json column defaults, applied on definition
|
|
963
|
+
* WRITES only. It lives outside `AppDefinitionSchema` on purpose: the schema
|
|
964
|
+
* also decodes persisted definitions (`decodeAppDefinition`, the row store's
|
|
965
|
+
* model lookup), and a cap lowered after an app was saved must not turn that
|
|
966
|
+
* app into one that "needs repair" on every read. Writing the definition
|
|
967
|
+
* again re-applies the current cap. An oversized default would otherwise be
|
|
968
|
+
* accepted only to fail every row create that relies on it.
|
|
969
|
+
*/
|
|
970
|
+
function jsonColumnDefaultSizeIssues(definition: AppDefinition): AppValidationIssue[] {
|
|
971
|
+
const issues: AppValidationIssue[] = [];
|
|
972
|
+
for (const [modelName, model] of Object.entries(definition.models)) {
|
|
973
|
+
for (const [columnName, column] of Object.entries(model.columns)) {
|
|
974
|
+
if (column.kind !== "json" || column.default === undefined) continue;
|
|
975
|
+
if (jsonColumnValueIssue(column.default) === "oversized") {
|
|
976
|
+
issues.push({
|
|
977
|
+
path: `models.${modelName}.columns.${columnName}.default`,
|
|
978
|
+
message: `json column default exceeds the ${jsonColumnSizeLabel()} size limit`,
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
return issues;
|
|
984
|
+
}
|
|
985
|
+
|
|
839
986
|
export async function parseAppDefinition(
|
|
840
987
|
input: unknown,
|
|
841
988
|
elementContext: AppDefinitionParseContext = {},
|
|
@@ -871,6 +1018,7 @@ export async function parseAppDefinition(
|
|
|
871
1018
|
if (!parsed.success) return { success: false, issues: appDefinitionIssues(parsed.error) };
|
|
872
1019
|
|
|
873
1020
|
const issues = [
|
|
1021
|
+
...jsonColumnDefaultSizeIssues(parsed.data),
|
|
874
1022
|
...Object.keys(parsed.data.pages).flatMap((pageName) =>
|
|
875
1023
|
validatePage(parsed.data, catalog, pageName),
|
|
876
1024
|
),
|
package/src/apps/row-store.ts
CHANGED
|
@@ -4,7 +4,9 @@ import {
|
|
|
4
4
|
AppDefinitionSchema,
|
|
5
5
|
type AppValidationIssue,
|
|
6
6
|
type ColumnDef,
|
|
7
|
-
|
|
7
|
+
isValidColumnValue,
|
|
8
|
+
jsonColumnSizeLabel,
|
|
9
|
+
jsonColumnValueIssue,
|
|
8
10
|
type ModelDef,
|
|
9
11
|
} from "./definition";
|
|
10
12
|
import { upgradeAppDefinition } from "./format-upgrades";
|
|
@@ -94,6 +96,7 @@ export function appIndexKey(model: string, column: string, value: unknown, rowId
|
|
|
94
96
|
|
|
95
97
|
function isIndexed(column: ColumnDef): boolean {
|
|
96
98
|
if (column.hidden === true) return false;
|
|
99
|
+
if (column.kind === "json") return false;
|
|
97
100
|
if (column.kind === "enum") return true;
|
|
98
101
|
return column.index === true && (column.kind === "string" || column.kind === "boolean");
|
|
99
102
|
}
|
|
@@ -111,11 +114,7 @@ function indexKeys(model: string, definition: ModelDef, row: AppRow): string[] {
|
|
|
111
114
|
|
|
112
115
|
export function validValue(column: ColumnDef, value: unknown): boolean {
|
|
113
116
|
if (value === null) return column.required !== true;
|
|
114
|
-
|
|
115
|
-
if (column.kind === "number") return typeof value === "number" && Number.isFinite(value);
|
|
116
|
-
if (column.kind === "boolean") return typeof value === "boolean";
|
|
117
|
-
if (column.kind === "date") return typeof value === "string" && isIso8601Date(value);
|
|
118
|
-
return typeof value === "string" && Boolean(column.enum?.includes(value));
|
|
117
|
+
return isValidColumnValue(column, value);
|
|
119
118
|
}
|
|
120
119
|
|
|
121
120
|
function joinKeyColumnNames(definition: ModelDef): Set<string> {
|
|
@@ -185,7 +184,20 @@ function prepareValues(
|
|
|
185
184
|
for (const [name, value] of Object.entries(prepared)) {
|
|
186
185
|
if (!Object.hasOwn(definition.columns, name)) continue;
|
|
187
186
|
const column = definition.columns[name]!;
|
|
188
|
-
if (
|
|
187
|
+
if (column.kind === "json" && value !== null) {
|
|
188
|
+
// Asked directly so the value is serialized once and the size cap gets its own message.
|
|
189
|
+
const issue = jsonColumnValueIssue(value);
|
|
190
|
+
if (issue === "unserializable") {
|
|
191
|
+
issues.push({ path: `values.${name}`, message: "must be a valid json value" });
|
|
192
|
+
} else if (issue === "non-finite") {
|
|
193
|
+
issues.push({ path: `values.${name}`, message: "json value contains a non-finite number" });
|
|
194
|
+
} else if (issue === "oversized") {
|
|
195
|
+
issues.push({
|
|
196
|
+
path: `values.${name}`,
|
|
197
|
+
message: `json value exceeds the ${jsonColumnSizeLabel()} size limit`,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
} else if (!validValue(column, value)) {
|
|
189
201
|
issues.push({ path: `values.${name}`, message: `must be a valid ${column.kind} value` });
|
|
190
202
|
}
|
|
191
203
|
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type AppValidationIssue,
|
|
9
9
|
type ColumnDef,
|
|
10
10
|
isIso8601Date,
|
|
11
|
+
isValidColumnValue,
|
|
11
12
|
type ModelDef,
|
|
12
13
|
parseAppDefinition,
|
|
13
14
|
type SourceDef,
|
|
@@ -23,7 +24,18 @@ import {
|
|
|
23
24
|
} from "./row-store";
|
|
24
25
|
import { invalidateChangedSyncStatus } from "./sync";
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Literal directive values (`set`, `map` targets, `else`): scalars, plus the
|
|
29
|
+
* objects and arrays a `json` column holds. The per-column kind check in
|
|
30
|
+
* `validateDirectiveValues` is what rejects a shape the target column cannot take.
|
|
31
|
+
*/
|
|
32
|
+
const MigrationValueSchema = z.union([
|
|
33
|
+
z.string(),
|
|
34
|
+
z.number(),
|
|
35
|
+
z.boolean(),
|
|
36
|
+
z.record(z.string(), z.unknown()),
|
|
37
|
+
z.array(z.unknown()),
|
|
38
|
+
]);
|
|
27
39
|
|
|
28
40
|
export const AppMigrationDirectiveSchema = z.union([
|
|
29
41
|
z.object({ set: MigrationValueSchema }).strict(),
|
|
@@ -368,11 +380,7 @@ function isPurgeDirective(
|
|
|
368
380
|
|
|
369
381
|
function columnAccepts(column: ColumnDef, value: unknown): boolean {
|
|
370
382
|
if (value === null) return column.hidden === true || column.required !== true;
|
|
371
|
-
|
|
372
|
-
if (column.kind === "number") return typeof value === "number" && Number.isFinite(value);
|
|
373
|
-
if (column.kind === "boolean") return typeof value === "boolean";
|
|
374
|
-
if (column.kind === "date") return typeof value === "string" && isIso8601Date(value);
|
|
375
|
-
return typeof value === "string" && Boolean(column.enum?.includes(value));
|
|
383
|
+
return isValidColumnValue(column, value);
|
|
376
384
|
}
|
|
377
385
|
|
|
378
386
|
function coerceValue(
|
package/src/apps/script-types.ts
CHANGED
|
@@ -69,6 +69,7 @@ function commentSafe(value: string): string {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
function tsTypeForColumn(column: Pick<ColumnDef, "kind" | "enum">): string {
|
|
72
|
+
if (column.kind === "json") return "unknown";
|
|
72
73
|
if (column.kind !== "enum") return column.kind === "date" ? "string" : column.kind;
|
|
73
74
|
return column.enum?.map((value) => JSON.stringify(value)).join(" | ") || "string";
|
|
74
75
|
}
|
package/src/apps/sync.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
type AppDefinition,
|
|
10
10
|
type AppValidationIssue,
|
|
11
11
|
type ColumnDef,
|
|
12
|
-
|
|
12
|
+
isValidColumnValue,
|
|
13
13
|
type ModelDef,
|
|
14
14
|
type SourceDef,
|
|
15
15
|
type SourceTransform,
|
|
@@ -484,19 +484,6 @@ function applyTransform(value: unknown, transform: SourceTransform | undefined):
|
|
|
484
484
|
}
|
|
485
485
|
}
|
|
486
486
|
|
|
487
|
-
/**
|
|
488
|
-
* Mirrors the row-store's per-kind value check. Projections are pre-validated
|
|
489
|
-
* here so one bad field nulls one column instead of throwing mid-reconcile and
|
|
490
|
-
* leaving the pass half-applied.
|
|
491
|
-
*/
|
|
492
|
-
function validForKind(column: ColumnDef, value: unknown): boolean {
|
|
493
|
-
if (column.kind === "string") return typeof value === "string";
|
|
494
|
-
if (column.kind === "number") return typeof value === "number" && Number.isFinite(value);
|
|
495
|
-
if (column.kind === "boolean") return typeof value === "boolean";
|
|
496
|
-
if (column.kind === "date") return typeof value === "string" && isIso8601Date(value);
|
|
497
|
-
return typeof value === "string" && Boolean(column.enum?.includes(value));
|
|
498
|
-
}
|
|
499
|
-
|
|
500
487
|
function projectRecord(args: {
|
|
501
488
|
record: SourceRecord;
|
|
502
489
|
bindings: Array<[string, ColumnDef]>;
|
|
@@ -518,7 +505,10 @@ function projectRecord(args: {
|
|
|
518
505
|
);
|
|
519
506
|
value = null;
|
|
520
507
|
}
|
|
521
|
-
|
|
508
|
+
// Projections are pre-validated with the row store's own kind check so one
|
|
509
|
+
// bad field nulls one column instead of throwing mid-reconcile and leaving
|
|
510
|
+
// the pass half-applied.
|
|
511
|
+
if (value !== null && !isValidColumnValue(column, value)) {
|
|
522
512
|
warn(
|
|
523
513
|
args.warnings,
|
|
524
514
|
`column "${name}": field "${binding.field}" produced a value that is not a valid ${column.kind}`,
|
|
@@ -530,9 +520,20 @@ function projectRecord(args: {
|
|
|
530
520
|
return values;
|
|
531
521
|
}
|
|
532
522
|
|
|
533
|
-
/**
|
|
534
|
-
|
|
535
|
-
|
|
523
|
+
/**
|
|
524
|
+
* A missing column and an explicit null are the same absence of a value. A
|
|
525
|
+
* json column compares structurally — the value decoded from storage is never
|
|
526
|
+
* the same object as a fresh projection — every other kind by identity.
|
|
527
|
+
*/
|
|
528
|
+
function sameValue(
|
|
529
|
+
column: ModelDef["columns"][string] | undefined,
|
|
530
|
+
existing: unknown,
|
|
531
|
+
projected: unknown,
|
|
532
|
+
): boolean {
|
|
533
|
+
const before = existing ?? null;
|
|
534
|
+
const after = projected ?? null;
|
|
535
|
+
if (column?.kind === "json") return Bun.deepEquals(before, after);
|
|
536
|
+
return Object.is(before, after);
|
|
536
537
|
}
|
|
537
538
|
|
|
538
539
|
// ---------------------------------------------------------------------------
|
|
@@ -701,7 +702,7 @@ function reconcile(args: {
|
|
|
701
702
|
return;
|
|
702
703
|
}
|
|
703
704
|
const differs = Object.entries(values).some(
|
|
704
|
-
([name, value]) => !sameValue(existing[name], value),
|
|
705
|
+
([name, value]) => !sameValue(modelDef.columns[name], existing[name], value),
|
|
705
706
|
);
|
|
706
707
|
const updated = await patchAppRowUnlocked(appId, model, modelDef, existing.id, values, {
|
|
707
708
|
allowSourceManaged: true,
|
package/src/be/db/tasks/read.ts
CHANGED
|
@@ -436,6 +436,118 @@ export async function getChildTasks(parentTaskId: string): Promise<AgentTask[]>
|
|
|
436
436
|
return rows.map(rowToAgentTask);
|
|
437
437
|
}
|
|
438
438
|
|
|
439
|
+
/** Caps for {@link getTaskLineage}: a fan-out of thousands still answers in one bounded read. */
|
|
440
|
+
export const TASK_LINEAGE_LIMITS = { maxNodes: 500, maxDepth: 30, maxAncestors: 50 } as const;
|
|
441
|
+
|
|
442
|
+
/** One task in a lineage tree: the slim list row plus the workflow run it belongs to. */
|
|
443
|
+
type TaskLineageNode = AgentTaskSummary & { workflowRunId?: string };
|
|
444
|
+
|
|
445
|
+
interface TaskLineage {
|
|
446
|
+
/** The topmost ancestor reached (the session root unless `truncated`). */
|
|
447
|
+
rootTaskId: string;
|
|
448
|
+
/** Root first, then descendants; always holds the path root → `taskId`. */
|
|
449
|
+
tasks: TaskLineageNode[];
|
|
450
|
+
/** True when some ancestor or descendant is missing from `tasks`. */
|
|
451
|
+
truncated: boolean;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const LINEAGE_COLUMNS = `${LIST_COLUMNS.slim}, agent_tasks.workflowRunId,
|
|
455
|
+
(SELECT SUM(totalCostUsd) FROM session_costs WHERE session_costs.taskId = agent_tasks.id) AS totalCostUsd`;
|
|
456
|
+
|
|
457
|
+
type TaskLineageRow = AgentTaskSummaryRow & { workflowRunId: string | null };
|
|
458
|
+
|
|
459
|
+
function rowToTaskLineageNode(row: TaskLineageRow): TaskLineageNode {
|
|
460
|
+
return { ...rowToAgentTaskSummary(row), workflowRunId: row.workflowRunId ?? undefined };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* The whole tree `taskId` belongs to, following `parentTaskId`: up to the
|
|
465
|
+
* topmost ancestor, then every descendant of that root breadth-first. The
|
|
466
|
+
* descendants of `taskId` itself are walked first, so a wide tree cut at
|
|
467
|
+
* `maxNodes` still shows the task's own subtree. Read-only and bounded: at
|
|
468
|
+
* most `maxAncestors` hops up, `maxDepth` levels down and about `2 * maxNodes`
|
|
469
|
+
* rows. Null when the task does not exist.
|
|
470
|
+
*/
|
|
471
|
+
export async function getTaskLineage(
|
|
472
|
+
taskId: string,
|
|
473
|
+
limits: { maxNodes?: number; maxDepth?: number; maxAncestors?: number } = {},
|
|
474
|
+
): Promise<TaskLineage | null> {
|
|
475
|
+
const maxNodes = limits.maxNodes ?? TASK_LINEAGE_LIMITS.maxNodes;
|
|
476
|
+
const maxDepth = limits.maxDepth ?? TASK_LINEAGE_LIMITS.maxDepth;
|
|
477
|
+
const maxAncestors = limits.maxAncestors ?? TASK_LINEAGE_LIMITS.maxAncestors;
|
|
478
|
+
const db = getDbClient();
|
|
479
|
+
// Hops are counted, so a parentTaskId cycle stops at the cap instead of looping.
|
|
480
|
+
const path = await db.query<{ id: string; parentTaskId: string | null; hops: number }>(
|
|
481
|
+
`WITH RECURSIVE up(id, parentTaskId, hops) AS (
|
|
482
|
+
SELECT id, parentTaskId, 0 FROM agent_tasks WHERE id = ?
|
|
483
|
+
UNION ALL
|
|
484
|
+
SELECT t.id, t.parentTaskId, up.hops + 1 FROM agent_tasks t
|
|
485
|
+
JOIN up ON t.id = up.parentTaskId
|
|
486
|
+
WHERE up.hops < ?
|
|
487
|
+
)
|
|
488
|
+
SELECT id, parentTaskId, hops FROM up ORDER BY hops`,
|
|
489
|
+
[taskId, maxAncestors],
|
|
490
|
+
);
|
|
491
|
+
if (path.length === 0) return null;
|
|
492
|
+
const top = path[path.length - 1]!;
|
|
493
|
+
// A parent that no longer exists ends the walk like a root; a cap does not.
|
|
494
|
+
let truncated = top.hops === maxAncestors && top.parentTaskId !== null;
|
|
495
|
+
|
|
496
|
+
// LIMIT inside the recursive CTE stops the walk itself, not just the output.
|
|
497
|
+
const descendants = (startId: string) =>
|
|
498
|
+
db.query<TaskLineageRow>(
|
|
499
|
+
`WITH RECURSIVE down(id, depth) AS (
|
|
500
|
+
SELECT ?, 0
|
|
501
|
+
UNION ALL
|
|
502
|
+
SELECT t.id, down.depth + 1 FROM down
|
|
503
|
+
JOIN agent_tasks t ON t.parentTaskId = down.id
|
|
504
|
+
WHERE down.depth < ?
|
|
505
|
+
ORDER BY 2
|
|
506
|
+
LIMIT ?
|
|
507
|
+
)
|
|
508
|
+
SELECT ${LINEAGE_COLUMNS} FROM down
|
|
509
|
+
CROSS JOIN agent_tasks ON agent_tasks.id = down.id
|
|
510
|
+
ORDER BY down.depth, agent_tasks.createdAt, agent_tasks.rowid`,
|
|
511
|
+
[startId, maxDepth, maxNodes + 1],
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
const byId = new Map<string, TaskLineageNode>();
|
|
515
|
+
const add = (rows: TaskLineageRow[]) => {
|
|
516
|
+
for (const row of rows) {
|
|
517
|
+
if (!byId.has(row.id)) byId.set(row.id, rowToTaskLineageNode(row));
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
const ownSubtree = await descendants(taskId);
|
|
521
|
+
const fromRoot = top.id === taskId ? ownSubtree : await descendants(top.id);
|
|
522
|
+
add(fromRoot.slice(0, maxNodes));
|
|
523
|
+
add(ownSubtree.slice(0, maxNodes));
|
|
524
|
+
|
|
525
|
+
// The path root → task is always present, even past the depth or node cap.
|
|
526
|
+
const missing = path.map((row) => row.id).filter((id) => !byId.has(id));
|
|
527
|
+
if (missing.length > 0) {
|
|
528
|
+
add(
|
|
529
|
+
await db.query<TaskLineageRow>(
|
|
530
|
+
`SELECT ${LINEAGE_COLUMNS} FROM agent_tasks WHERE id IN (${missing.map(() => "?").join(", ")})`,
|
|
531
|
+
missing,
|
|
532
|
+
),
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// The caps cut the walk at many points (depth, node count, either walk), so
|
|
537
|
+
// ask the table directly: is any child of a returned task not returned?
|
|
538
|
+
if (!truncated) {
|
|
539
|
+
const ids = [...byId.keys()];
|
|
540
|
+
const list = ids.map(() => "?").join(", ");
|
|
541
|
+
const omitted = await db.get(
|
|
542
|
+
`SELECT 1 FROM agent_tasks WHERE parentTaskId IN (${list}) AND id NOT IN (${list}) LIMIT 1`,
|
|
543
|
+
[...ids, ...ids],
|
|
544
|
+
);
|
|
545
|
+
truncated = omitted !== undefined && omitted !== null;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return { rootTaskId: top.id, tasks: [...byId.values()], truncated };
|
|
549
|
+
}
|
|
550
|
+
|
|
439
551
|
/**
|
|
440
552
|
* Returns true if `parentId` has at least one non-terminal child task with
|
|
441
553
|
* `taskType = 'resume'`. Used by the heartbeat sweep as an idempotency guard:
|
package/src/be/db.ts
CHANGED
|
@@ -327,6 +327,7 @@ export {
|
|
|
327
327
|
getSupersededTasksWithoutResume,
|
|
328
328
|
getSupersededTasksWithUnsettledDependents,
|
|
329
329
|
getTaskById,
|
|
330
|
+
getTaskLineage,
|
|
330
331
|
getTaskStats,
|
|
331
332
|
getTasksByAgentId,
|
|
332
333
|
getTasksByStatus,
|
|
@@ -346,6 +347,7 @@ export {
|
|
|
346
347
|
recordChatRelayError,
|
|
347
348
|
resetTasksNotified,
|
|
348
349
|
SUPERSEDED_RESUME_REPAIR_WINDOW_MS,
|
|
350
|
+
TASK_LINEAGE_LIMITS,
|
|
349
351
|
type TaskFilters,
|
|
350
352
|
} from "./db/tasks/read";
|
|
351
353
|
|
|
@@ -139,6 +139,16 @@ export const DB_RETENTION_TUNING_BOUNDS = {
|
|
|
139
139
|
DB_RETENTION_MAX_STATEMENT_MS: { min: 25, max: 5_000 },
|
|
140
140
|
} as const;
|
|
141
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Bounds for the Swarm Apps json column size cap (`APPS_JSON_COLUMN_MAX_BYTES`).
|
|
144
|
+
*
|
|
145
|
+
* `src/apps/definition.ts` reads the knob against exactly these at call time
|
|
146
|
+
* and falls back to its 256 KB default outside them; VALIDATED_KEYS below
|
|
147
|
+
* rejects out-of-range writes with the same numbers, so the config API cannot
|
|
148
|
+
* accept a cap the row store would silently ignore.
|
|
149
|
+
*/
|
|
150
|
+
export const APPS_JSON_COLUMN_MAX_BYTES_BOUNDS = { min: 1_024, max: 16_777_216 } as const;
|
|
151
|
+
|
|
142
152
|
/** Build `{ KEY: validator }` entries accepting only boolean literals. */
|
|
143
153
|
function booleanValidators(keys: string[]): Record<string, ConfigValidator> {
|
|
144
154
|
const message = (key: string) =>
|
|
@@ -527,6 +537,11 @@ const VALIDATED_KEYS: Record<string, ConfigValidator> = {
|
|
|
527
537
|
// an operator could save a 500ms tick budget, see it accepted, and have the
|
|
528
538
|
// sweep keep running for the default 30000ms.
|
|
529
539
|
...boundedIntegerValidatorsFor(DB_RETENTION_TUNING_BOUNDS),
|
|
540
|
+
...boundedIntegerValidators(
|
|
541
|
+
["APPS_JSON_COLUMN_MAX_BYTES"],
|
|
542
|
+
APPS_JSON_COLUMN_MAX_BYTES_BOUNDS.min,
|
|
543
|
+
APPS_JSON_COLUMN_MAX_BYTES_BOUNDS.max,
|
|
544
|
+
),
|
|
530
545
|
...boundedIntegerValidators(
|
|
531
546
|
[
|
|
532
547
|
"SESSION_LOG_RETENTION_DAYS",
|
package/src/http/apps.ts
CHANGED
|
@@ -8,10 +8,10 @@ import {
|
|
|
8
8
|
type AppQueryDef,
|
|
9
9
|
type AppValidationIssue,
|
|
10
10
|
applyAppDefinitionPatch,
|
|
11
|
-
ColumnKindSchema,
|
|
12
11
|
isIso8601Date,
|
|
13
12
|
type ModelDef,
|
|
14
13
|
parseAppDefinition,
|
|
14
|
+
ScalarColumnKindSchema,
|
|
15
15
|
SORTABLE_SYSTEM_DATE_COLUMNS,
|
|
16
16
|
SYSTEM_COLUMN_KINDS,
|
|
17
17
|
scriptCompatWarnings,
|
|
@@ -216,7 +216,7 @@ const UserConfigValuesSchema = z.record(
|
|
|
216
216
|
* on the wire anyway — the key can only ever be absent.
|
|
217
217
|
*/
|
|
218
218
|
const AppUserConfigFieldSchema = z.object({
|
|
219
|
-
kind:
|
|
219
|
+
kind: ScalarColumnKindSchema,
|
|
220
220
|
default: z.union([z.string(), z.number(), z.boolean()]).optional(),
|
|
221
221
|
enum: z.array(z.string()).optional(),
|
|
222
222
|
label: z.string().optional(),
|
|
@@ -785,6 +785,7 @@ async function resolveModel(
|
|
|
785
785
|
}
|
|
786
786
|
|
|
787
787
|
function parseFilterValue(raw: string, column: ModelDef["columns"][string]): unknown {
|
|
788
|
+
if (column.kind === "json") throw new Error("json columns cannot be filtered");
|
|
788
789
|
if (column.kind === "number") {
|
|
789
790
|
if (!DECIMAL_NUMBER_PATTERN.test(raw)) throw new Error("must be a decimal number");
|
|
790
791
|
const value = Number(raw);
|
|
@@ -1017,6 +1018,15 @@ export async function selectAppRows(
|
|
|
1017
1018
|
limit = 200,
|
|
1018
1019
|
systemColumns = false,
|
|
1019
1020
|
) {
|
|
1021
|
+
// Backstop for every caller: the REST route (`filter.<col>`) and the MCP
|
|
1022
|
+
// tools (`equals.<col>`) reject json filters with their own paths first.
|
|
1023
|
+
const jsonFilterIssues = filters.flatMap((filter) =>
|
|
1024
|
+
Object.hasOwn(model.columns, filter.column) && model.columns[filter.column]!.kind === "json"
|
|
1025
|
+
? [{ path: `filter.${filter.column}`, message: "json columns cannot be filtered" }]
|
|
1026
|
+
: [],
|
|
1027
|
+
);
|
|
1028
|
+
if (jsonFilterIssues.length > 0) throw new AppRowValidationError(jsonFilterIssues);
|
|
1029
|
+
|
|
1020
1030
|
// SQL narrows the scan; the `===` re-check keeps the exact filter semantics.
|
|
1021
1031
|
const rows = (
|
|
1022
1032
|
await listAppRows(
|
|
@@ -1046,6 +1056,9 @@ export async function selectAppRows(
|
|
|
1046
1056
|
{ path: "sort", message: "must be <column>:<asc|desc> for a known column" },
|
|
1047
1057
|
]);
|
|
1048
1058
|
}
|
|
1059
|
+
if (model.columns[column]?.kind === "json") {
|
|
1060
|
+
throw new AppRowValidationError([{ path: "sort", message: "json columns cannot be sorted" }]);
|
|
1061
|
+
}
|
|
1049
1062
|
rows.sort((a, b) => {
|
|
1050
1063
|
const result = compareValues(
|
|
1051
1064
|
rowValue(a, column),
|