@mulmoclaude/core 0.22.1 → 0.23.1
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/assets/helps/collection-skills.md +110 -1
- package/assets/helps/custom-view.md +38 -0
- package/assets/helps/error-recovery.md +36 -0
- package/assets/skills-preset/mc-zenn/SKILL.md +31 -0
- package/dist/collection/core/queryZ.d.ts +90 -0
- package/dist/collection/core/schema.d.ts +14 -1
- package/dist/collection/core/schemaZ.d.ts +18 -1
- package/dist/collection/index.cjs +3 -2
- package/dist/collection/index.cjs.map +1 -1
- package/dist/collection/index.d.ts +1 -0
- package/dist/collection/index.js +3 -3
- package/dist/collection/index.js.map +1 -1
- package/dist/collection/registry/server/index.cjs +12 -4
- package/dist/collection/registry/server/index.cjs.map +1 -1
- package/dist/collection/registry/server/index.js +12 -4
- package/dist/collection/registry/server/index.js.map +1 -1
- package/dist/collection/server/csvQuery.d.ts +27 -0
- package/dist/collection/server/csvStore.d.ts +51 -0
- package/dist/collection/server/discoveredCollection.d.ts +9 -1
- package/dist/collection/server/discovery.d.ts +7 -3
- package/dist/collection/server/index.cjs +17 -2
- package/dist/collection/server/index.d.ts +6 -0
- package/dist/collection/server/index.js +3 -3
- package/dist/collection/server/jsonlQuery.d.ts +4 -0
- package/dist/collection/server/manageTool.d.ts +4 -0
- package/dist/collection/server/store.d.ts +31 -0
- package/dist/collection-watchers/index.cjs +104 -3
- package/dist/collection-watchers/index.cjs.map +1 -1
- package/dist/collection-watchers/index.js +102 -3
- package/dist/collection-watchers/index.js.map +1 -1
- package/dist/feeds/index.cjs +2 -2
- package/dist/feeds/index.js +2 -2
- package/dist/feeds/server/index.cjs +3 -3
- package/dist/feeds/server/index.js +3 -3
- package/dist/google/auth.d.ts +18 -3
- package/dist/google/authFlow.d.ts +1 -0
- package/dist/google/calendar.d.ts +44 -0
- package/dist/google/index.cjs +165 -47
- package/dist/google/index.cjs.map +1 -1
- package/dist/google/index.d.ts +2 -2
- package/dist/google/index.js +159 -48
- package/dist/google/index.js.map +1 -1
- package/dist/{ingestTypes-DEjpiZGM.js → ingestTypes-B-dXxUF9.js} +2 -2
- package/dist/{ingestTypes-DEjpiZGM.js.map → ingestTypes-B-dXxUF9.js.map} +1 -1
- package/dist/{ingestTypes-Dh5lniBL.cjs → ingestTypes-Cev9q77C.cjs} +2 -2
- package/dist/{ingestTypes-Dh5lniBL.cjs.map → ingestTypes-Cev9q77C.cjs.map} +1 -1
- package/dist/{promptSafety-DN5V__Ku.cjs → promptSafety-DRd15gQ6.cjs} +15 -1
- package/dist/promptSafety-DRd15gQ6.cjs.map +1 -0
- package/dist/{promptSafety-DyG3EuC7.js → promptSafety-rDWA9_JS.js} +10 -2
- package/dist/promptSafety-rDWA9_JS.js.map +1 -0
- package/dist/{server-Bd8l1bhv.js → server-D2ibbixF.js} +772 -79
- package/dist/server-D2ibbixF.js.map +1 -0
- package/dist/{server-Bh-DPK-H.cjs → server-Jaogu3Ky.cjs} +860 -76
- package/dist/server-Jaogu3Ky.cjs.map +1 -0
- package/package.json +3 -1
- package/dist/promptSafety-DN5V__Ku.cjs.map +0 -1
- package/dist/promptSafety-DyG3EuC7.js.map +0 -1
- package/dist/server-Bd8l1bhv.js.map +0 -1
- package/dist/server-Bh-DPK-H.cjs.map +0 -1
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { B as isFieldDrivenEvery, C as backlinkRows, D as actionVisible, E as rollupValue, F as AGENT_INGEST_KIND, I as COMPUTED_TYPES, L as FEED_SCHEDULES, M as SAFE_SLUG_PATTERN, N as isSafeRecordId, P as isSafeSlug, R as INGEST_KINDS, T as projectBacklinkRow, b as resolveIcon, d as parseIsoDateTime, g as deriveAll, j as SAFE_RECORD_ID_PATTERN, t as defangForPrompt, u as parseIsoDate, w as coerceNumeric, x as selectDynamicRecord, y as firstDateField, z as embedTargetId } from "./promptSafety-
|
|
1
|
+
import { B as isFieldDrivenEvery, C as backlinkRows, D as actionVisible, E as rollupValue, F as AGENT_INGEST_KIND, I as COMPUTED_TYPES, L as FEED_SCHEDULES, M as SAFE_SLUG_PATTERN, N as isSafeRecordId, P as isSafeSlug, R as INGEST_KINDS, T as projectBacklinkRow, V as isReadOnlySchema, b as resolveIcon, d as parseIsoDateTime, g as deriveAll, j as SAFE_RECORD_ID_PATTERN, t as defangForPrompt, u as parseIsoDate, w as coerceNumeric, x as selectDynamicRecord, y as firstDateField, z as embedTargetId } from "./promptSafety-rDWA9_JS.js";
|
|
2
2
|
import { isSafeActionTemplatePath, isSafeCustomViewI18nPath, isSafeCustomViewPath } from "./collection/paths.js";
|
|
3
3
|
import { dataSkillDir, mirrorSkillWrite } from "./skill-bridge/index.js";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { promises, realpathSync } from "node:fs";
|
|
6
|
-
import { cp, lstat, mkdir, open, readFile, readdir, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
|
|
7
|
-
import { randomBytes, randomUUID } from "node:crypto";
|
|
6
|
+
import { cp, lstat, mkdir, open, readFile, readdir, rename, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
|
|
7
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import iconv from "iconv-lite";
|
|
8
10
|
import { z } from "zod";
|
|
9
11
|
//#region src/collection/server/host.ts
|
|
10
12
|
var current = null;
|
|
@@ -572,7 +574,7 @@ function promptPathsFor(collection, workspaceRoot) {
|
|
|
572
574
|
const skillDir = (rel === "" || rel.startsWith("..") ? collection.skillDir : rel).split(path.sep).join("/");
|
|
573
575
|
return {
|
|
574
576
|
slug: collection.slug,
|
|
575
|
-
dataPath: collection.schema.dataPath,
|
|
577
|
+
dataPath: collection.schema.dataPath ?? collection.schema.dataSource?.path ?? "",
|
|
576
578
|
skillDir
|
|
577
579
|
};
|
|
578
580
|
}
|
|
@@ -635,6 +637,586 @@ ${dataJson}
|
|
|
635
637
|
${templateText}`;
|
|
636
638
|
}
|
|
637
639
|
//#endregion
|
|
640
|
+
//#region src/collection/core/queryZ.ts
|
|
641
|
+
/** Result-column aliases double as SQL identifiers and JSON keys — keep
|
|
642
|
+
* them to a conservative identifier charset so neither side needs
|
|
643
|
+
* escaping gymnastics. */
|
|
644
|
+
var SAFE_ALIAS_PATTERN = /^[A-Za-z_]\w{0,63}$/;
|
|
645
|
+
/** Hard ceiling on returned rows; `limit` clamps below it. A group-by on
|
|
646
|
+
* a near-unique column would otherwise return one row per source row —
|
|
647
|
+
* the exact materialization the aggregate path exists to avoid. */
|
|
648
|
+
var MAX_QUERY_ROWS = 1e4;
|
|
649
|
+
/** Default row cap when the query declares no `limit`. */
|
|
650
|
+
var DEFAULT_QUERY_ROWS = 1e3;
|
|
651
|
+
/** One aggregate column: `count` (rows; `column` optional to count
|
|
652
|
+
* non-null cells) or `sum`/`avg`/`min`/`max` over a named CSV column. */
|
|
653
|
+
var QueryAggregateZ = z.object({
|
|
654
|
+
op: z.enum([
|
|
655
|
+
"count",
|
|
656
|
+
"sum",
|
|
657
|
+
"avg",
|
|
658
|
+
"min",
|
|
659
|
+
"max"
|
|
660
|
+
]),
|
|
661
|
+
column: z.string().min(1).optional()
|
|
662
|
+
}).refine((aggregate) => aggregate.op === "count" || aggregate.column !== void 0, {
|
|
663
|
+
message: "`column` is required for every aggregate op except `count`",
|
|
664
|
+
path: ["column"]
|
|
665
|
+
});
|
|
666
|
+
/** One filter condition. Same op vocabulary as the schema-level `where`
|
|
667
|
+
* (`core/where.ts`) so authors learn one set; values may be typed
|
|
668
|
+
* (number / boolean) since CSV columns are. `in` requires an array
|
|
669
|
+
* value, every other op a scalar. */
|
|
670
|
+
var QueryWhereZ = z.object({
|
|
671
|
+
field: z.string().min(1),
|
|
672
|
+
op: z.enum([
|
|
673
|
+
"eq",
|
|
674
|
+
"ne",
|
|
675
|
+
"in",
|
|
676
|
+
"gt",
|
|
677
|
+
"gte",
|
|
678
|
+
"lt",
|
|
679
|
+
"lte",
|
|
680
|
+
"contains"
|
|
681
|
+
]),
|
|
682
|
+
value: z.union([
|
|
683
|
+
z.string(),
|
|
684
|
+
z.number(),
|
|
685
|
+
z.boolean(),
|
|
686
|
+
z.array(z.union([
|
|
687
|
+
z.string(),
|
|
688
|
+
z.number(),
|
|
689
|
+
z.boolean()
|
|
690
|
+
])).min(1).max(100)
|
|
691
|
+
])
|
|
692
|
+
}).refine((cond) => cond.op === "in" === Array.isArray(cond.value), {
|
|
693
|
+
message: "`in` requires an array value (the allowed set); every other op requires a scalar value",
|
|
694
|
+
path: ["value"]
|
|
695
|
+
});
|
|
696
|
+
var QueryOrderZ = z.object({
|
|
697
|
+
/** A `groupBy` column or an aggregate alias — membership enforced by
|
|
698
|
+
* the whole-query refine below. */
|
|
699
|
+
field: z.string().min(1),
|
|
700
|
+
dir: z.enum(["asc", "desc"]).optional()
|
|
701
|
+
});
|
|
702
|
+
/** The whole query. At least one of `groupBy` / `aggregates` must be
|
|
703
|
+
* present: bare `groupBy` is a DISTINCT listing, bare `aggregates` a
|
|
704
|
+
* whole-file scalar row, together a grouped aggregation. */
|
|
705
|
+
var CollectionQueryZ = z.object({
|
|
706
|
+
groupBy: z.array(z.string().min(1)).max(8).refine((columns) => new Set(columns.map((column) => column.toLowerCase())).size === columns.length, { message: "`groupBy` columns must be unique (case-insensitively — SQL identifiers ignore case)" }).optional(),
|
|
707
|
+
aggregates: z.record(z.string().regex(SAFE_ALIAS_PATTERN, "aggregate aliases must be simple identifiers (letters/digits/underscore)"), QueryAggregateZ).optional(),
|
|
708
|
+
where: z.array(QueryWhereZ).max(16).optional(),
|
|
709
|
+
orderBy: z.array(QueryOrderZ).max(4).optional(),
|
|
710
|
+
limit: z.number().int().min(1).max(MAX_QUERY_ROWS).optional()
|
|
711
|
+
}).refine((query) => (query.groupBy?.length ?? 0) > 0 || Object.keys(query.aggregates ?? {}).length > 0, {
|
|
712
|
+
message: "declare at least one of `groupBy` (columns to bucket by) or `aggregates` (values to compute)",
|
|
713
|
+
path: ["groupBy"]
|
|
714
|
+
}).refine((query) => Object.keys(query.aggregates ?? {}).length <= 32, {
|
|
715
|
+
message: `\`aggregates\` supports at most 32 entries`,
|
|
716
|
+
path: ["aggregates"]
|
|
717
|
+
}).refine((query) => {
|
|
718
|
+
const groupLower = new Set((query.groupBy ?? []).map((column) => column.toLowerCase()));
|
|
719
|
+
const seen = /* @__PURE__ */ new Set();
|
|
720
|
+
return Object.keys(query.aggregates ?? {}).every((alias) => {
|
|
721
|
+
const lower = alias.toLowerCase();
|
|
722
|
+
if (groupLower.has(lower) || seen.has(lower)) return false;
|
|
723
|
+
seen.add(lower);
|
|
724
|
+
return true;
|
|
725
|
+
});
|
|
726
|
+
}, {
|
|
727
|
+
message: "aggregate aliases must be unique and must not collide with `groupBy` column names (case-insensitively — SQL identifiers ignore case)",
|
|
728
|
+
path: ["aggregates"]
|
|
729
|
+
}).refine((query) => {
|
|
730
|
+
const sortable = /* @__PURE__ */ new Set([...query.groupBy ?? [], ...Object.keys(query.aggregates ?? {})]);
|
|
731
|
+
return (query.orderBy ?? []).every((order) => sortable.has(order.field));
|
|
732
|
+
}, {
|
|
733
|
+
message: "every `orderBy.field` must be a `groupBy` column or an aggregate alias",
|
|
734
|
+
path: ["orderBy"]
|
|
735
|
+
});
|
|
736
|
+
//#endregion
|
|
737
|
+
//#region src/collection/server/csvQuery.ts
|
|
738
|
+
/** Double-quote a SQL identifier (CSV column name / result alias). */
|
|
739
|
+
function quoteIdent(name) {
|
|
740
|
+
return `"${name.replaceAll("\"", "\"\"")}"`;
|
|
741
|
+
}
|
|
742
|
+
/** Single-quote a SQL string literal (a `types={...}` struct key). */
|
|
743
|
+
function quoteLiteral(value) {
|
|
744
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
745
|
+
}
|
|
746
|
+
/** The `read_csv` argument list shared by every CSV query: the (prepared)
|
|
747
|
+
* path plus a `types` pin forcing the key column to VARCHAR — without it
|
|
748
|
+
* DuckDB's sniffer turns `001` into BIGINT 1, so leading zeros vanish
|
|
749
|
+
* and distinct keys collapse. */
|
|
750
|
+
function readCsvArgs(primaryKey) {
|
|
751
|
+
return `?, types={${quoteLiteral(primaryKey)}: 'VARCHAR'}`;
|
|
752
|
+
}
|
|
753
|
+
/** One aggregate's SQL expression. `sum`/`avg` TRY_CAST to DOUBLE so a
|
|
754
|
+
* column the sniffer kept as VARCHAR (mixed values) aggregates over its
|
|
755
|
+
* numeric cells instead of erroring; non-numeric cells become NULL and
|
|
756
|
+
* are skipped — standard BI tolerance. `min`/`max` stay native (they are
|
|
757
|
+
* meaningful on strings and dates too). */
|
|
758
|
+
function aggregateExpr(aggregate) {
|
|
759
|
+
const { op, column } = aggregate;
|
|
760
|
+
if (op === "count") return column === void 0 ? "count(*)" : `count(${quoteIdent(column)})`;
|
|
761
|
+
if (op === "sum" || op === "avg") return `${op}(TRY_CAST(${quoteIdent(column ?? "")} AS DOUBLE))`;
|
|
762
|
+
return `${op}(${quoteIdent(column ?? "")})`;
|
|
763
|
+
}
|
|
764
|
+
/** One where condition → SQL fragment + its bound parameters. String
|
|
765
|
+
* equality compares against `CAST(col AS VARCHAR)` so a sniffer-typed
|
|
766
|
+
* column still matches its textual value; numeric/boolean values compare
|
|
767
|
+
* natively (DuckDB coerces the column side). */
|
|
768
|
+
function whereFragment(cond) {
|
|
769
|
+
const column = quoteIdent(cond.field);
|
|
770
|
+
const asText = `CAST(${column} AS VARCHAR)`;
|
|
771
|
+
if (cond.op === "in") {
|
|
772
|
+
const values = cond.value;
|
|
773
|
+
return {
|
|
774
|
+
sql: `${values.every((value) => typeof value === "string") ? asText : column} IN (${values.map(() => "?").join(", ")})`,
|
|
775
|
+
params: values
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
if (cond.op === "contains") return {
|
|
779
|
+
sql: `contains(${asText}, ?)`,
|
|
780
|
+
params: [String(cond.value)]
|
|
781
|
+
};
|
|
782
|
+
const operator = {
|
|
783
|
+
eq: "=",
|
|
784
|
+
ne: "<>",
|
|
785
|
+
gt: ">",
|
|
786
|
+
gte: ">=",
|
|
787
|
+
lt: "<",
|
|
788
|
+
lte: "<="
|
|
789
|
+
}[cond.op];
|
|
790
|
+
return {
|
|
791
|
+
sql: `${typeof cond.value === "string" && (cond.op === "eq" || cond.op === "ne") ? asText : column} ${operator} ?`,
|
|
792
|
+
params: [cond.value]
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
/** Compile a validated query against `fromSql` (a table-function call
|
|
796
|
+
* whose FIRST placeholder is the source path — the executor binds it).
|
|
797
|
+
* Returns the SQL and the where-value parameters that follow the path.
|
|
798
|
+
* Callers MUST have run `CollectionQueryZ` first; this function trusts
|
|
799
|
+
* the shape (aliases already charset-checked, orderBy membership already
|
|
800
|
+
* enforced). */
|
|
801
|
+
function compileQuery(query, fromSql) {
|
|
802
|
+
const groupBy = query.groupBy ?? [];
|
|
803
|
+
const aggregates = Object.entries(query.aggregates ?? {});
|
|
804
|
+
const selectList = [...groupBy.map(quoteIdent), ...aggregates.map(([alias, aggregate]) => `${aggregateExpr(aggregate)} AS ${quoteIdent(alias)}`)];
|
|
805
|
+
const where = (query.where ?? []).map(whereFragment);
|
|
806
|
+
const clauses = [`SELECT ${selectList.join(", ")}`, `FROM ${fromSql}`];
|
|
807
|
+
if (where.length > 0) clauses.push(`WHERE ${where.map((fragment) => fragment.sql).join(" AND ")}`);
|
|
808
|
+
if (groupBy.length > 0) clauses.push(`GROUP BY ${groupBy.map(quoteIdent).join(", ")}`);
|
|
809
|
+
const orderBy = (query.orderBy ?? []).map((order) => quoteIdent(order.field) + (order.dir === "desc" ? " DESC" : " ASC"));
|
|
810
|
+
if (orderBy.length > 0) clauses.push(`ORDER BY ${orderBy.join(", ")}`);
|
|
811
|
+
clauses.push(`LIMIT ${query.limit ?? 1e3}`);
|
|
812
|
+
return {
|
|
813
|
+
sql: clauses.join(" "),
|
|
814
|
+
params: where.flatMap((fragment) => fragment.params)
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
/** Compile against a CSV file (the dataSource store's engine). */
|
|
818
|
+
function compileCsvQuery(query, primaryKey) {
|
|
819
|
+
return compileQuery(query, `read_csv(${readCsvArgs(primaryKey)})`);
|
|
820
|
+
}
|
|
821
|
+
/** Compile against a JSONL file of ENRICHED records — the file-backed
|
|
822
|
+
* collections' engine (see `jsonlQuery.ts`). No VARCHAR key pin needed:
|
|
823
|
+
* enriched record ids are already strings. `sample_size=-1` makes the
|
|
824
|
+
* schema inference scan EVERY line — with the default sample, a sparse
|
|
825
|
+
* optional/derived field first appearing past the sample would not be
|
|
826
|
+
* inferred as a column and the query would binder-error on it (Codex P2
|
|
827
|
+
* on #2165). The full scan costs nothing extra here: aggregation reads
|
|
828
|
+
* the whole file anyway. */
|
|
829
|
+
function compileJsonlQuery(query) {
|
|
830
|
+
return compileQuery(query, `read_json(?, format='newline_delimited', sample_size=-1)`);
|
|
831
|
+
}
|
|
832
|
+
//#endregion
|
|
833
|
+
//#region src/collection/server/csvStore.ts
|
|
834
|
+
/** `list()` row cap. Over-cap files are truncated with a warn — the v1
|
|
835
|
+
* contract is "browse + per-record views", not full-table analytics. */
|
|
836
|
+
var MAX_CSV_ROWS = 5e3;
|
|
837
|
+
/** Record ids minted from non-safe key values: `id0x` + utf-8 hex. Raw key
|
|
838
|
+
* values that themselves match this pattern are ALSO encoded, so the
|
|
839
|
+
* encoded namespace never collides with a raw value (injective mapping). */
|
|
840
|
+
var ENCODED_ID_PATTERN = /^id0x([0-9a-f]+)$/;
|
|
841
|
+
/** A CSV key value → the record id it's addressed by. Safe values pass
|
|
842
|
+
* through untouched; everything else (and anything shaped like an encoded
|
|
843
|
+
* id) becomes `id0x<hex>`. Pure + exported for unit tests. */
|
|
844
|
+
function encodeCsvRecordId(rawKey) {
|
|
845
|
+
if (safeRecordId(rawKey) === rawKey && !ENCODED_ID_PATTERN.test(rawKey)) return rawKey;
|
|
846
|
+
return `id0x${Buffer.from(rawKey, "utf-8").toString("hex")}`;
|
|
847
|
+
}
|
|
848
|
+
/** A record id → the CSV key value to look up. Inverse of
|
|
849
|
+
* `encodeCsvRecordId` for encoded ids; anything else is already the raw
|
|
850
|
+
* value. Pure + exported for unit tests. */
|
|
851
|
+
function decodeCsvRecordId(itemId) {
|
|
852
|
+
const match = ENCODED_ID_PATTERN.exec(itemId);
|
|
853
|
+
if (!match) return itemId;
|
|
854
|
+
return Buffer.from(match[1], "hex").toString("utf-8");
|
|
855
|
+
}
|
|
856
|
+
/** Normalize one DuckDB JS value into a JSON-safe record value: BigInt →
|
|
857
|
+
* number (string beyond the safe range), DATE/TIMESTAMP → ISO string
|
|
858
|
+
* (date-only when the clock is exactly UTC midnight, matching the `date`
|
|
859
|
+
* field contract), exotic DuckDB values → their string form. Pure +
|
|
860
|
+
* exported for unit tests. */
|
|
861
|
+
function normalizeCsvValue(value) {
|
|
862
|
+
if (typeof value === "bigint") return value <= BigInt(Number.MAX_SAFE_INTEGER) && value >= BigInt(-Number.MAX_SAFE_INTEGER) ? Number(value) : value.toString();
|
|
863
|
+
if (value instanceof Date) {
|
|
864
|
+
const iso = value.toISOString();
|
|
865
|
+
return iso.endsWith("T00:00:00.000Z") ? iso.slice(0, 10) : iso;
|
|
866
|
+
}
|
|
867
|
+
if (value !== null && typeof value === "object") return String(value);
|
|
868
|
+
return value;
|
|
869
|
+
}
|
|
870
|
+
/** One raw DuckDB row → a CollectionItem, or null when the key cell is
|
|
871
|
+
* missing/empty (the row can't be addressed). The primaryKey field is
|
|
872
|
+
* OVERWRITTEN with the (possibly encoded) record id so `item[primaryKey]`
|
|
873
|
+
* and the record's address never drift — same invariant the file store's
|
|
874
|
+
* write path enforces. Pure + exported for unit tests. */
|
|
875
|
+
function csvRowToItem(row, primaryKey) {
|
|
876
|
+
const normalized = Object.fromEntries(Object.entries(row).map(([key, value]) => [key, normalizeCsvValue(value)]));
|
|
877
|
+
const rawKey = normalized[primaryKey];
|
|
878
|
+
if (rawKey === null || rawKey === void 0 || String(rawKey) === "") return null;
|
|
879
|
+
return {
|
|
880
|
+
...normalized,
|
|
881
|
+
[primaryKey]: encodeCsvRecordId(String(rawKey))
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
/** Dedupe by record id, LAST row wins (matches `csvRead`'s last-match
|
|
885
|
+
* pick). Returns the surviving items in first-seen order. Pure +
|
|
886
|
+
* exported for unit tests. */
|
|
887
|
+
function dedupeByRecordId(items, primaryKey) {
|
|
888
|
+
const byId = /* @__PURE__ */ new Map();
|
|
889
|
+
for (const item of items) byId.set(String(item[primaryKey]), item);
|
|
890
|
+
return {
|
|
891
|
+
items: [...byId.values()],
|
|
892
|
+
duplicates: items.length - byId.size
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
/** True when a thrown DuckDB error is the `types` pin naming a column the
|
|
896
|
+
* CSV doesn't have — the schema/file-mismatch case the caller downgrades
|
|
897
|
+
* to "empty collection + warn" instead of a 500. */
|
|
898
|
+
function isMissingKeyColumnError(err) {
|
|
899
|
+
return String(err).includes("do not exist in the CSV");
|
|
900
|
+
}
|
|
901
|
+
/** Bytes sniffed for UTF-8 validity. The trailing 3 bytes of the sample
|
|
902
|
+
* are dropped so a multibyte char split at the boundary can't produce a
|
|
903
|
+
* false negative on a valid file. */
|
|
904
|
+
var SNIFF_BYTES = 1024 * 1024;
|
|
905
|
+
function isValidUtf8(buf) {
|
|
906
|
+
try {
|
|
907
|
+
new TextDecoder("utf-8", { fatal: true }).decode(buf);
|
|
908
|
+
return true;
|
|
909
|
+
} catch {
|
|
910
|
+
return false;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
/** Detect the (best-effort) encoding of a non-UTF-8 buffer. BOMs decide
|
|
914
|
+
* UTF-16; otherwise cp932 (the Shift_JIS superset — Excel-exported
|
|
915
|
+
* Japanese CSVs are the primary non-UTF-8 case this feature serves). */
|
|
916
|
+
function fallbackEncoding(buf) {
|
|
917
|
+
if (buf.length >= 2 && buf[0] === 255 && buf[1] === 254) return "utf-16le";
|
|
918
|
+
if (buf.length >= 2 && buf[0] === 254 && buf[1] === 255) return "utf-16be";
|
|
919
|
+
return "cp932";
|
|
920
|
+
}
|
|
921
|
+
function cacheDir() {
|
|
922
|
+
return path.join(tmpdir(), "mulmoclaude-csv-utf8");
|
|
923
|
+
}
|
|
924
|
+
/** Read only the first `bytes` of a file — the encoding sniff must not
|
|
925
|
+
* pull a multi-hundred-MB CSV into memory on the (common) UTF-8 path. */
|
|
926
|
+
async function readHead(absPath, bytes) {
|
|
927
|
+
const handle = await open(absPath, "r");
|
|
928
|
+
try {
|
|
929
|
+
const { size } = await handle.stat();
|
|
930
|
+
const buf = Buffer.alloc(Math.min(bytes, size));
|
|
931
|
+
await handle.read(buf, 0, buf.length, 0);
|
|
932
|
+
return buf;
|
|
933
|
+
} finally {
|
|
934
|
+
await handle.close();
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
/** Decode the whole file into a UTF-8 cache copy and return its path.
|
|
938
|
+
* Cache key = (path, mtime, size), so a replaced CSV re-decodes and an
|
|
939
|
+
* unchanged one never does. */
|
|
940
|
+
async function pathExists$1(target) {
|
|
941
|
+
try {
|
|
942
|
+
await stat(target);
|
|
943
|
+
return true;
|
|
944
|
+
} catch {
|
|
945
|
+
return false;
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
/** Best-effort removal of older decode-cache entries for the same source
|
|
949
|
+
* path — a frequently-replaced large CSV would otherwise accumulate one
|
|
950
|
+
* full copy per (mtime, size) forever. Runs AFTER the current copy is
|
|
951
|
+
* published; a concurrent reader holding an old fd is unaffected
|
|
952
|
+
* (unlink-while-open is safe on POSIX). */
|
|
953
|
+
async function evictSupersededCache(key, keepBasename) {
|
|
954
|
+
try {
|
|
955
|
+
const entries = await readdir(cacheDir());
|
|
956
|
+
await Promise.all(entries.filter((name) => name.startsWith(`${key}-`) && name !== keepBasename).map((name) => unlink(path.join(cacheDir(), name)).catch(() => void 0)));
|
|
957
|
+
} catch {}
|
|
958
|
+
}
|
|
959
|
+
/** Decode the whole file into a UTF-8 cache copy and return its path.
|
|
960
|
+
* Cache key = (path, mtime, size), so a replaced CSV re-decodes and an
|
|
961
|
+
* unchanged one never does; superseded copies are evicted. The cache
|
|
962
|
+
* lives in the SHARED OS tmpdir, so the dir is 0700 and files 0600 —
|
|
963
|
+
* decoded rows must not be readable by other local users. */
|
|
964
|
+
async function decodeToCache(absPath, info) {
|
|
965
|
+
const key = createHash("sha256").update(absPath).digest("hex").slice(0, 16);
|
|
966
|
+
const cached = path.join(cacheDir(), `${key}-${Math.trunc(info.mtimeMs)}-${info.size}.csv`);
|
|
967
|
+
if (!await pathExists$1(cached)) {
|
|
968
|
+
const whole = await readFile(absPath);
|
|
969
|
+
const encoding = fallbackEncoding(whole);
|
|
970
|
+
const text = iconv.decode(whole, encoding);
|
|
971
|
+
await mkdir(cacheDir(), {
|
|
972
|
+
recursive: true,
|
|
973
|
+
mode: 448
|
|
974
|
+
});
|
|
975
|
+
const tmp = `${cached}.${randomBytes(4).toString("hex")}.tmp`;
|
|
976
|
+
await writeFile(tmp, text, {
|
|
977
|
+
encoding: "utf-8",
|
|
978
|
+
mode: 384
|
|
979
|
+
});
|
|
980
|
+
await rename(tmp, cached);
|
|
981
|
+
log.info("collections", "decoded non-UTF-8 dataSource file to cache", {
|
|
982
|
+
path: absPath,
|
|
983
|
+
encoding
|
|
984
|
+
});
|
|
985
|
+
await evictSupersededCache(key, path.basename(cached));
|
|
986
|
+
}
|
|
987
|
+
return cached;
|
|
988
|
+
}
|
|
989
|
+
/** Re-validate the dataSource file at READ time, mirroring the JSON
|
|
990
|
+
* store's per-read defenses: realpath containment (a symlink swapped in
|
|
991
|
+
* after discovery must not walk out of the workspace) and an lstat
|
|
992
|
+
* regular-file check (a symlink leaf is refused outright, even one
|
|
993
|
+
* pointing inside the workspace — same rule as `isRegularFile` on
|
|
994
|
+
* record files). Returns the stat info, or null for "no readable file"
|
|
995
|
+
* (ENOENT / refused), which callers render as an empty collection. */
|
|
996
|
+
async function safeCsvStat(absPath, workspaceRoot) {
|
|
997
|
+
if (!isContainedInRoot(absPath, workspaceRoot)) {
|
|
998
|
+
log.warn("collections", "dataSource read refused: path escapes workspace", { path: absPath });
|
|
999
|
+
return null;
|
|
1000
|
+
}
|
|
1001
|
+
let info;
|
|
1002
|
+
try {
|
|
1003
|
+
info = await lstat(absPath);
|
|
1004
|
+
} catch (err) {
|
|
1005
|
+
if (err.code === "ENOENT") return null;
|
|
1006
|
+
throw err;
|
|
1007
|
+
}
|
|
1008
|
+
if (!info.isFile()) {
|
|
1009
|
+
log.warn("collections", "dataSource read refused: not a regular file (symlink?)", { path: absPath });
|
|
1010
|
+
return null;
|
|
1011
|
+
}
|
|
1012
|
+
return info;
|
|
1013
|
+
}
|
|
1014
|
+
/** Return a path DuckDB can read as UTF-8: the original file when it
|
|
1015
|
+
* already is UTF-8 (the cheap, common case — only the head is sniffed),
|
|
1016
|
+
* else a decoded cache copy (see `decodeToCache`). Returns null when
|
|
1017
|
+
* there is no readable file (missing, symlink, or containment-refused —
|
|
1018
|
+
* see `safeCsvStat`), which callers render as an empty collection. */
|
|
1019
|
+
async function ensureUtf8CsvPath(absPath, workspaceRoot) {
|
|
1020
|
+
const info = await safeCsvStat(absPath, workspaceRoot);
|
|
1021
|
+
if (info === null) return null;
|
|
1022
|
+
const head = await readHead(absPath, SNIFF_BYTES);
|
|
1023
|
+
const sample = head.length === SNIFF_BYTES ? head.subarray(0, SNIFF_BYTES - 3) : head;
|
|
1024
|
+
if (!(head.length >= 2 && (head[0] === 255 && head[1] === 254 || head[0] === 254 && head[1] === 255)) && isValidUtf8(sample)) return absPath;
|
|
1025
|
+
return decodeToCache(absPath, info);
|
|
1026
|
+
}
|
|
1027
|
+
var instancePromise = null;
|
|
1028
|
+
/** Lazily create one shared in-memory DuckDB instance. The dynamic import
|
|
1029
|
+
* keeps the native module OUT of core's load path — a platform where the
|
|
1030
|
+
* prebuilt binding is missing degrades to a per-query error on dataSource
|
|
1031
|
+
* collections only, never a broken core. A failed init is retried on the
|
|
1032
|
+
* next call (the promise is reset). */
|
|
1033
|
+
async function duckDbInstance() {
|
|
1034
|
+
if (instancePromise === null) instancePromise = import("@duckdb/node-api").then((mod) => mod.DuckDBInstance.create(":memory:"));
|
|
1035
|
+
try {
|
|
1036
|
+
return await instancePromise;
|
|
1037
|
+
} catch (err) {
|
|
1038
|
+
instancePromise = null;
|
|
1039
|
+
throw new Error(`DuckDB is unavailable on this host (@duckdb/node-api failed to load: ${String(err)}) — dataSource collections cannot be read`);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
async function queryCsv(sql, params) {
|
|
1043
|
+
const connection = await (await duckDbInstance()).connect();
|
|
1044
|
+
try {
|
|
1045
|
+
return (await connection.runAndReadAll(sql, params)).getRowObjectsJS();
|
|
1046
|
+
} finally {
|
|
1047
|
+
connection.disconnectSync();
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
/** Every row of the CSV as records — capped, deduped, id-encoded. The
|
|
1051
|
+
* key column is pinned to VARCHAR (see `readCsvArgs`). `workspaceRoot`
|
|
1052
|
+
* drives the per-read containment check; omitted, the configured host
|
|
1053
|
+
* root is used. */
|
|
1054
|
+
async function csvList(absPath, primaryKey, workspaceRoot) {
|
|
1055
|
+
const utf8Path = await ensureUtf8CsvPath(absPath, workspaceRoot ?? getWorkspaceRoot());
|
|
1056
|
+
if (utf8Path === null) return [];
|
|
1057
|
+
let rows;
|
|
1058
|
+
try {
|
|
1059
|
+
rows = await queryCsv(`SELECT * FROM read_csv(${readCsvArgs(primaryKey)}) LIMIT 5001`, [utf8Path]);
|
|
1060
|
+
} catch (err) {
|
|
1061
|
+
if (!isMissingKeyColumnError(err)) throw err;
|
|
1062
|
+
log.warn("collections", "dataSource CSV has no primaryKey column — every row is skipped", {
|
|
1063
|
+
path: absPath,
|
|
1064
|
+
primaryKey
|
|
1065
|
+
});
|
|
1066
|
+
return [];
|
|
1067
|
+
}
|
|
1068
|
+
if (rows.length > 5e3) {
|
|
1069
|
+
log.warn("collections", "dataSource CSV truncated to row cap", {
|
|
1070
|
+
path: absPath,
|
|
1071
|
+
cap: MAX_CSV_ROWS
|
|
1072
|
+
});
|
|
1073
|
+
rows.length = MAX_CSV_ROWS;
|
|
1074
|
+
}
|
|
1075
|
+
const items = rows.map((row) => csvRowToItem(row, primaryKey)).filter((item) => item !== null);
|
|
1076
|
+
const skipped = rows.length - items.length;
|
|
1077
|
+
if (skipped > 0) log.warn("collections", "dataSource CSV rows skipped (empty key cell)", {
|
|
1078
|
+
path: absPath,
|
|
1079
|
+
skipped
|
|
1080
|
+
});
|
|
1081
|
+
const deduped = dedupeByRecordId(items, primaryKey);
|
|
1082
|
+
if (deduped.duplicates > 0) log.warn("collections", "dataSource CSV has duplicate key values (last row wins)", {
|
|
1083
|
+
path: absPath,
|
|
1084
|
+
duplicates: deduped.duplicates
|
|
1085
|
+
});
|
|
1086
|
+
return deduped.items;
|
|
1087
|
+
}
|
|
1088
|
+
/** The scan-order ordinal column the last-match read adds. Underscore
|
|
1089
|
+
* prefix keeps it out of any plausible CSV header namespace; it is
|
|
1090
|
+
* stripped from the returned record either way. */
|
|
1091
|
+
var ROW_ORDINAL = "__mc_row";
|
|
1092
|
+
/** One record by id. The comparison value rides as a prepared-statement
|
|
1093
|
+
* parameter, and the LAST matching row is selected IN DuckDB (scan-order
|
|
1094
|
+
* ordinal + LIMIT 1) — a CSV with thousands of duplicate keys must not
|
|
1095
|
+
* materialize them all for one detail read. Consistent with csvList's
|
|
1096
|
+
* last-wins dedupe. */
|
|
1097
|
+
async function csvRead(absPath, primaryKey, itemId, workspaceRoot) {
|
|
1098
|
+
const utf8Path = await ensureUtf8CsvPath(absPath, workspaceRoot ?? getWorkspaceRoot());
|
|
1099
|
+
if (utf8Path === null) return null;
|
|
1100
|
+
const rawKey = decodeCsvRecordId(itemId);
|
|
1101
|
+
const last = (await queryCsv(`SELECT * FROM (SELECT *, row_number() OVER () AS ${quoteIdent(ROW_ORDINAL)} FROM read_csv(${readCsvArgs(primaryKey)})) WHERE CAST(${quoteIdent(primaryKey)} AS VARCHAR) = ? ORDER BY ${quoteIdent(ROW_ORDINAL)} DESC LIMIT 1`, [utf8Path, rawKey])).at(0);
|
|
1102
|
+
if (last === void 0) return null;
|
|
1103
|
+
const { [ROW_ORDINAL]: __ordinal, ...record } = last;
|
|
1104
|
+
return csvRowToItem(record, primaryKey);
|
|
1105
|
+
}
|
|
1106
|
+
/** Run a validated aggregation query (the structured DSL — see
|
|
1107
|
+
* `core/queryZ.ts`) over the WHOLE file: no row cap on the scan (a
|
|
1108
|
+
* capped aggregate would be a wrong number), only the result-row LIMIT
|
|
1109
|
+
* the compiler emits. Values are normalized like list/read rows so a
|
|
1110
|
+
* chart consumer gets plain JSON scalars. */
|
|
1111
|
+
async function csvRunQuery(absPath, primaryKey, query, workspaceRoot) {
|
|
1112
|
+
const utf8Path = await ensureUtf8CsvPath(absPath, workspaceRoot ?? getWorkspaceRoot());
|
|
1113
|
+
if (utf8Path === null) return [];
|
|
1114
|
+
const { sql, params } = compileCsvQuery(query, primaryKey);
|
|
1115
|
+
return (await queryCsv(sql, [utf8Path, ...params])).map((row) => Object.fromEntries(Object.entries(row).map(([key, value]) => [key, normalizeCsvValue(value)])));
|
|
1116
|
+
}
|
|
1117
|
+
//#endregion
|
|
1118
|
+
//#region src/collection/server/store.ts
|
|
1119
|
+
/** True when the collection accepts UI/tool writes. A `dataSource`
|
|
1120
|
+
* collection is read-only: updates happen by editing/replacing the
|
|
1121
|
+
* data file itself. Every write entry point checks this BEFORE calling
|
|
1122
|
+
* `writeItem`/`deleteItem` — server-enforced, not just UI-hidden. */
|
|
1123
|
+
function collectionWritable(collection) {
|
|
1124
|
+
return !isReadOnlySchema(collection.schema);
|
|
1125
|
+
}
|
|
1126
|
+
/** The one-line refusal write paths surface (HTTP 405 / MCP error text). */
|
|
1127
|
+
function readOnlyRefusal(slug) {
|
|
1128
|
+
return `collection '${slug}' is read-only (backed by an external dataSource) — update the data file itself instead`;
|
|
1129
|
+
}
|
|
1130
|
+
/** Pick the store implementation for a discovered collection. A
|
|
1131
|
+
* `dataSource` schema whose `dataSourceFile` failed to resolve yields a
|
|
1132
|
+
* read-only EMPTY store rather than falling back to the (writable) file
|
|
1133
|
+
* store — a half-loaded read-only collection must never become writable. */
|
|
1134
|
+
function storeFor(collection, opts = {}) {
|
|
1135
|
+
if (isReadOnlySchema(collection.schema)) {
|
|
1136
|
+
const file = collection.dataSourceFile;
|
|
1137
|
+
const key = collection.schema.primaryKey;
|
|
1138
|
+
return {
|
|
1139
|
+
capabilities: { writable: false },
|
|
1140
|
+
list: () => file === void 0 ? Promise.resolve([]) : csvList(file, key, opts.workspaceRoot),
|
|
1141
|
+
read: (itemId) => file === void 0 ? Promise.resolve(null) : csvRead(file, key, itemId, opts.workspaceRoot),
|
|
1142
|
+
query: (query) => file === void 0 ? Promise.resolve([]) : csvRunQuery(file, key, query, opts.workspaceRoot)
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
return {
|
|
1146
|
+
capabilities: { writable: true },
|
|
1147
|
+
list: () => listItems(collection.dataDir, opts),
|
|
1148
|
+
read: (itemId) => readItem(collection.dataDir, itemId, opts)
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
//#endregion
|
|
1152
|
+
//#region src/collection/server/jsonlQuery.ts
|
|
1153
|
+
/** SQL semantics for an aggregate-only query over ZERO rows: one scalar
|
|
1154
|
+
* row (`count` = 0, everything else NULL) — the same shape the CSV path
|
|
1155
|
+
* produces for a header-only file, so callers reading `rows[0]` never
|
|
1156
|
+
* break on storage kind or emptiness. A grouped query over zero rows is
|
|
1157
|
+
* zero groups (`[]`), also matching SQL. Synthesized here because DuckDB
|
|
1158
|
+
* has no empty file to infer a schema from. */
|
|
1159
|
+
function emptyCollectionResult(query) {
|
|
1160
|
+
if ((query.groupBy?.length ?? 0) > 0) return [];
|
|
1161
|
+
const aggregates = Object.entries(query.aggregates ?? {});
|
|
1162
|
+
return [Object.fromEntries(aggregates.map(([alias, aggregate]) => [alias, aggregate.op === "count" ? 0 : null]))];
|
|
1163
|
+
}
|
|
1164
|
+
/** The SOURCE columns a query reads: groupBy columns, aggregate input
|
|
1165
|
+
* columns, and where fields. (orderBy resolves to groupBy columns or
|
|
1166
|
+
* aggregate ALIASES — never to a new source column — so it adds none.) */
|
|
1167
|
+
function referencedSourceColumns(query) {
|
|
1168
|
+
const columns = new Set(query.groupBy ?? []);
|
|
1169
|
+
for (const aggregate of Object.values(query.aggregates ?? {})) if (aggregate.column !== void 0) columns.add(aggregate.column);
|
|
1170
|
+
for (const cond of query.where ?? []) columns.add(cond.field);
|
|
1171
|
+
return [...columns];
|
|
1172
|
+
}
|
|
1173
|
+
/** Referenced columns that appear in NO row — a freshly-added optional
|
|
1174
|
+
* field, or a derived field whose inputs no record has yet. DuckDB only
|
|
1175
|
+
* infers columns from keys that occur in the JSONL, so without help a
|
|
1176
|
+
* valid query on such a field binder-errors here while the CSV path
|
|
1177
|
+
* (whose header always declares the column) returns NULLs. Padding the
|
|
1178
|
+
* FIRST line with `col: null` makes the column exist (full-scan
|
|
1179
|
+
* inference unions keys across lines) with matching NULL semantics. */
|
|
1180
|
+
function absentReferencedColumns(rows, query) {
|
|
1181
|
+
const present = /* @__PURE__ */ new Set();
|
|
1182
|
+
for (const row of rows) for (const key of Object.keys(row)) present.add(key);
|
|
1183
|
+
return referencedSourceColumns(query).filter((column) => !present.has(column));
|
|
1184
|
+
}
|
|
1185
|
+
/** Run a validated query over enriched collection rows. */
|
|
1186
|
+
async function runQueryOverRows(rows, query) {
|
|
1187
|
+
if (rows.length === 0) return emptyCollectionResult(query);
|
|
1188
|
+
await mkdir(cacheDir(), {
|
|
1189
|
+
recursive: true,
|
|
1190
|
+
mode: 448
|
|
1191
|
+
});
|
|
1192
|
+
const jsonlPath = path.join(cacheDir(), `q-${randomBytes(8).toString("hex")}.jsonl`);
|
|
1193
|
+
try {
|
|
1194
|
+
const nullPads = Object.fromEntries(absentReferencedColumns(rows, query).map((column) => [column, null]));
|
|
1195
|
+
const handle = await open(jsonlPath, "wx", 384);
|
|
1196
|
+
try {
|
|
1197
|
+
let first = true;
|
|
1198
|
+
for (const row of rows) {
|
|
1199
|
+
await handle.write(`${JSON.stringify(first ? {
|
|
1200
|
+
...nullPads,
|
|
1201
|
+
...row
|
|
1202
|
+
} : row)}\n`);
|
|
1203
|
+
first = false;
|
|
1204
|
+
}
|
|
1205
|
+
} finally {
|
|
1206
|
+
await handle.close();
|
|
1207
|
+
}
|
|
1208
|
+
const { sql, params } = compileJsonlQuery(query);
|
|
1209
|
+
return (await queryCsv(sql, [jsonlPath, ...params])).map((row) => Object.fromEntries(Object.entries(row).map(([key, value]) => [key, normalizeCsvValue(value)])));
|
|
1210
|
+
} finally {
|
|
1211
|
+
await unlink(jsonlPath).catch((err) => {
|
|
1212
|
+
if (err.code !== "ENOENT") log.warn("collections", "temp JSONL cleanup failed", {
|
|
1213
|
+
path: jsonlPath,
|
|
1214
|
+
error: String(err)
|
|
1215
|
+
});
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
//#endregion
|
|
638
1220
|
//#region src/collection/core/recordZ.ts
|
|
639
1221
|
/** The emptiness rule shared by `required` and the "only check present
|
|
640
1222
|
* values" gate. NOT a truthiness check — `0` and `false` are filled. */
|
|
@@ -751,6 +1333,7 @@ async function listRecordFilenames(dataDir, workspaceRoot) {
|
|
|
751
1333
|
}
|
|
752
1334
|
}
|
|
753
1335
|
async function validateCollectionRecords(collection, opts = {}) {
|
|
1336
|
+
if (collection.schema.dataSource !== void 0) return [];
|
|
754
1337
|
const workspaceRoot = opts.workspaceRoot ?? getWorkspaceRoot();
|
|
755
1338
|
const entries = await listRecordFilenames(collection.dataDir, workspaceRoot);
|
|
756
1339
|
const issues = [];
|
|
@@ -1400,10 +1983,24 @@ function fieldDrivenFromFieldCarried(schema) {
|
|
|
1400
1983
|
}
|
|
1401
1984
|
return (carry ?? []).includes(driven.fromField);
|
|
1402
1985
|
}
|
|
1986
|
+
/** External-data collection: the records ARE the rows of a user-supplied
|
|
1987
|
+
* data file (v1: CSV), queried through DuckDB — never copied into
|
|
1988
|
+
* `<dataDir>/<id>.json` files. Declaring `dataSource` makes the collection
|
|
1989
|
+
* **read-only** in every UI/tool write path; updates happen by replacing /
|
|
1990
|
+
* editing the file itself (file-watch republishes the views). `path` is
|
|
1991
|
+
* workspace-relative and containment-checked exactly like `dataPath`. The
|
|
1992
|
+
* row-id column is the schema's existing `primaryKey` — there is
|
|
1993
|
+
* deliberately no second key concept here.
|
|
1994
|
+
* See plans/feat-collection-csv-duckdb-source.md. */
|
|
1995
|
+
var DataSourceZ = z.object({
|
|
1996
|
+
type: z.literal("csv"),
|
|
1997
|
+
path: z.string().min(1)
|
|
1998
|
+
});
|
|
1403
1999
|
var CollectionSchemaZ = z.object({
|
|
1404
2000
|
title: z.string().min(1),
|
|
1405
2001
|
icon: z.string().min(1),
|
|
1406
|
-
dataPath: z.string().min(1),
|
|
2002
|
+
dataPath: z.string().min(1).optional(),
|
|
2003
|
+
dataSource: DataSourceZ.optional(),
|
|
1407
2004
|
primaryKey: z.string().min(1),
|
|
1408
2005
|
singleton: z.string().trim().min(1).optional(),
|
|
1409
2006
|
fields: z.record(z.string(), FieldSpecZ),
|
|
@@ -1423,6 +2020,15 @@ var CollectionSchemaZ = z.object({
|
|
|
1423
2020
|
notifyWhen: WhenZ.optional(),
|
|
1424
2021
|
ingest: IngestZ.optional(),
|
|
1425
2022
|
dynamicIcon: DynamicIconSpecZ.optional()
|
|
2023
|
+
}).refine((schema) => schema.dataPath !== void 0 !== (schema.dataSource !== void 0), {
|
|
2024
|
+
message: "declare exactly one of `dataPath` (native JSON records) or `dataSource` (external read-only data file), never both or neither",
|
|
2025
|
+
path: ["dataPath"]
|
|
2026
|
+
}).refine((schema) => schema.dataSource === void 0 || schema.singleton === void 0 && schema.ingest === void 0 && schema.spawn === void 0, {
|
|
2027
|
+
message: "a `dataSource` collection is read-only — it cannot declare `singleton`, `ingest`, or `spawn` (all of them write records)",
|
|
2028
|
+
path: ["dataSource"]
|
|
2029
|
+
}).refine((schema) => schema.dataSource === void 0 || [...schema.actions ?? [], ...schema.collectionActions ?? []].every((action) => action.kind !== "mutate"), {
|
|
2030
|
+
message: "a `dataSource` collection is read-only — its actions cannot use `kind: \"mutate\"` (a host write); use `chat`/`agent` actions instead",
|
|
2031
|
+
path: ["dataSource"]
|
|
1426
2032
|
}).refine((schema) => schema.singleton === void 0 || isSafeRecordId(schema.singleton), {
|
|
1427
2033
|
message: "schema `singleton` must be a valid item id (alphanumeric / hyphen / underscore / interior dot, no `..` or path separators)",
|
|
1428
2034
|
path: ["singleton"]
|
|
@@ -1548,6 +2154,12 @@ function applyFeedSchemaDefaults(parsed, slug) {
|
|
|
1548
2154
|
dataPath: `data/feeds/${slug}`
|
|
1549
2155
|
};
|
|
1550
2156
|
}
|
|
2157
|
+
/** The conventional per-slug records dir a `dataSource` collection gets as
|
|
2158
|
+
* its `dataDir` (records never live there, but archive/delete paths stay
|
|
2159
|
+
* well-defined — same shape the registry's R3 normalization uses). */
|
|
2160
|
+
function conventionalDataPath(slug) {
|
|
2161
|
+
return `data/collections/${slug}/items`;
|
|
2162
|
+
}
|
|
1551
2163
|
/** The acceptance gates discovery applies AFTER `CollectionSchemaZ` parses,
|
|
1552
2164
|
* before a schema becomes a live collection:
|
|
1553
2165
|
*
|
|
@@ -1557,7 +2169,8 @@ function applyFeedSchemaDefaults(parsed, slug) {
|
|
|
1557
2169
|
* edit is dropped with no error;
|
|
1558
2170
|
* - a `feed` schema must declare an `ingest` block (else it's a dead,
|
|
1559
2171
|
* non-refreshable card);
|
|
1560
|
-
* - `dataPath` must resolve INSIDE the
|
|
2172
|
+
* - `dataPath` — or a `dataSource`'s `path` — must resolve INSIDE the
|
|
2173
|
+
* workspace (same realpath containment for both).
|
|
1561
2174
|
*
|
|
1562
2175
|
* Exported so `manageCollection`'s `putSchema` can run the SAME gates before
|
|
1563
2176
|
* it reports success — a schema that passes `CollectionSchemaZ` but fails one
|
|
@@ -1577,7 +2190,24 @@ function acceptParsedSchema(schema, opts) {
|
|
|
1577
2190
|
ok: false,
|
|
1578
2191
|
reason: "a feed schema must declare an `ingest` block"
|
|
1579
2192
|
};
|
|
1580
|
-
|
|
2193
|
+
if (schema.dataSource !== void 0) {
|
|
2194
|
+
const dataSourceFile = resolveDataDir(schema.dataSource.path, opts.workspaceRoot);
|
|
2195
|
+
if (dataSourceFile === null) return {
|
|
2196
|
+
ok: false,
|
|
2197
|
+
reason: `dataSource.path '${schema.dataSource.path}' escapes the workspace`
|
|
2198
|
+
};
|
|
2199
|
+
const dataDir = resolveDataDir(conventionalDataPath(opts.slug), opts.workspaceRoot);
|
|
2200
|
+
if (dataDir === null) return {
|
|
2201
|
+
ok: false,
|
|
2202
|
+
reason: `slug '${opts.slug}' yields no workspace-contained data dir`
|
|
2203
|
+
};
|
|
2204
|
+
return {
|
|
2205
|
+
ok: true,
|
|
2206
|
+
dataDir,
|
|
2207
|
+
dataSourceFile
|
|
2208
|
+
};
|
|
2209
|
+
}
|
|
2210
|
+
const dataDir = resolveDataDir(schema.dataPath ?? "", opts.workspaceRoot);
|
|
1581
2211
|
if (dataDir === null) return {
|
|
1582
2212
|
ok: false,
|
|
1583
2213
|
reason: `dataPath '${schema.dataPath}' escapes the workspace`
|
|
@@ -1625,7 +2255,8 @@ async function loadOneCollection(skillsRoot, slug, source, workspaceRoot) {
|
|
|
1625
2255
|
const schema = parsed.data;
|
|
1626
2256
|
const acceptance = acceptParsedSchema(schema, {
|
|
1627
2257
|
source,
|
|
1628
|
-
workspaceRoot
|
|
2258
|
+
workspaceRoot,
|
|
2259
|
+
slug: safeName
|
|
1629
2260
|
});
|
|
1630
2261
|
if (!acceptance.ok) {
|
|
1631
2262
|
log.warn("collections", "schema.json rejected after validation, skipping", {
|
|
@@ -1639,6 +2270,7 @@ async function loadOneCollection(skillsRoot, slug, source, workspaceRoot) {
|
|
|
1639
2270
|
source,
|
|
1640
2271
|
schema,
|
|
1641
2272
|
dataDir: acceptance.dataDir,
|
|
2273
|
+
...acceptance.dataSourceFile !== void 0 ? { dataSourceFile: acceptance.dataSourceFile } : {},
|
|
1642
2274
|
skillDir: path.join(skillsRoot, safeName)
|
|
1643
2275
|
};
|
|
1644
2276
|
}
|
|
@@ -1710,7 +2342,8 @@ function toSummary(collection) {
|
|
|
1710
2342
|
slug: collection.slug,
|
|
1711
2343
|
title: collection.schema.title,
|
|
1712
2344
|
icon: collection.schema.icon,
|
|
1713
|
-
source: collection.source
|
|
2345
|
+
source: collection.source,
|
|
2346
|
+
...collection.schema.dataSource !== void 0 ? { readonly: true } : {}
|
|
1714
2347
|
};
|
|
1715
2348
|
}
|
|
1716
2349
|
function toDetail(collection) {
|
|
@@ -1765,6 +2398,20 @@ async function countRecordFiles(dataDir, workspaceRoot) {
|
|
|
1765
2398
|
return 0;
|
|
1766
2399
|
}
|
|
1767
2400
|
}
|
|
2401
|
+
/** A collection's record count for the ontology. File-backed: the cheap
|
|
2402
|
+
* readdir count. `dataSource`-backed: the row count via the CSV store
|
|
2403
|
+
* (subject to its row cap) — the dataDir is a phantom there, so the
|
|
2404
|
+
* readdir count would always misreport 0. Fail-soft to 0 like
|
|
2405
|
+
* `countRecordFiles` (an unreadable file / missing DuckDB must not
|
|
2406
|
+
* break the whole ontology). */
|
|
2407
|
+
async function countRecords(collection, workspaceRoot) {
|
|
2408
|
+
if (!collectionWritable(collection)) try {
|
|
2409
|
+
return (await storeFor(collection, { workspaceRoot }).list()).length;
|
|
2410
|
+
} catch {
|
|
2411
|
+
return 0;
|
|
2412
|
+
}
|
|
2413
|
+
return countRecordFiles(collection.dataDir, workspaceRoot);
|
|
2414
|
+
}
|
|
1768
2415
|
async function toOntologyEntry(collection, workspaceRoot) {
|
|
1769
2416
|
const { schema } = collection;
|
|
1770
2417
|
return {
|
|
@@ -1773,7 +2420,7 @@ async function toOntologyEntry(collection, workspaceRoot) {
|
|
|
1773
2420
|
icon: schema.icon,
|
|
1774
2421
|
primaryKey: schema.primaryKey,
|
|
1775
2422
|
displayField: schema.displayField ?? schema.primaryKey,
|
|
1776
|
-
recordCount: await
|
|
2423
|
+
recordCount: await countRecords(collection, workspaceRoot),
|
|
1777
2424
|
relations: schemaRelations(schema)
|
|
1778
2425
|
};
|
|
1779
2426
|
}
|
|
@@ -1819,7 +2466,7 @@ function uniqueBacklinkSources(schema) {
|
|
|
1819
2466
|
async function loadTarget(slug, opts) {
|
|
1820
2467
|
const target = await loadCollection(slug, opts);
|
|
1821
2468
|
if (!target) return null;
|
|
1822
|
-
const items = await
|
|
2469
|
+
const items = await storeFor(target, { workspaceRoot: opts.workspaceRoot }).list();
|
|
1823
2470
|
const byId = {};
|
|
1824
2471
|
for (const item of items) {
|
|
1825
2472
|
const itemId = item[target.schema.primaryKey];
|
|
@@ -1937,7 +2584,7 @@ async function computeCollectionIcon(collection, opts = {}) {
|
|
|
1937
2584
|
try {
|
|
1938
2585
|
const source = await loadCollection(spec.source.collection, opts);
|
|
1939
2586
|
if (!source) return spec.fallback ?? schema.icon;
|
|
1940
|
-
const ordered = sortByPrimaryKey(await
|
|
2587
|
+
const ordered = sortByPrimaryKey(await storeFor(source, { workspaceRoot: opts.workspaceRoot }).list(), source.schema.primaryKey);
|
|
1941
2588
|
const orderBy = spec.source.orderBy ?? firstDateField(source.schema);
|
|
1942
2589
|
const recordsById = buildRecordsById(ordered, source.schema.primaryKey);
|
|
1943
2590
|
return resolveIcon(selectDynamicRecord(ordered, spec.source, orderBy, recordsById), spec, schema.icon, recordsById);
|
|
@@ -2235,6 +2882,20 @@ function isDataDirSafe(dataDir, slug, workspaceRoot) {
|
|
|
2235
2882
|
const resolved = path.resolve(dataDir);
|
|
2236
2883
|
return acceptableRoots.some((root) => resolved === root || resolved.startsWith(root + path.sep));
|
|
2237
2884
|
}
|
|
2885
|
+
/** Step 2 of the restore doc — how to bring the records back. A
|
|
2886
|
+
* `dataSource` collection has no record files to copy (its rows live in
|
|
2887
|
+
* the external data file, which the delete never touches). */
|
|
2888
|
+
function restoreRecordsStep(schema) {
|
|
2889
|
+
if (schema.dataPath === void 0) return `2. Records: nothing to copy. This is a \`dataSource\` collection —
|
|
2890
|
+
its records are the rows of \`${schema.dataSource?.path}\`, which the
|
|
2891
|
+
delete never touched.`;
|
|
2892
|
+
return `2. Copy the item data: \`cp\` every file under \`records/\` into
|
|
2893
|
+
\`${schema.dataPath}/\`. The records are part of the collection and
|
|
2894
|
+
must be restored. They are plain data files (NOT bridged), so use
|
|
2895
|
+
\`cp\` — the Write-tool rule in step 1 applies ONLY to the skill
|
|
2896
|
+
files, not to these records (there may be many; copy them, do not
|
|
2897
|
+
Write them one by one).`;
|
|
2898
|
+
}
|
|
2238
2899
|
function buildRestoreDoc(collection) {
|
|
2239
2900
|
const { slug, schema } = collection;
|
|
2240
2901
|
return `# Restore "${schema.title}" (collection \`${slug}\`)
|
|
@@ -2255,18 +2916,13 @@ Follow these steps to restore it.
|
|
|
2255
2916
|
\`.claude/skills/\` directly is not an option either: that path is
|
|
2256
2917
|
permission-gated.)
|
|
2257
2918
|
|
|
2258
|
-
|
|
2259
|
-
\`${schema.dataPath}/\`. The records are part of the collection and
|
|
2260
|
-
must be restored. They are plain data files (NOT bridged), so use
|
|
2261
|
-
\`cp\` — the Write-tool rule in step 1 applies ONLY to the skill
|
|
2262
|
-
files, not to these records (there may be many; copy them, do not
|
|
2263
|
-
Write them one by one).
|
|
2919
|
+
${restoreRecordsStep(schema)}
|
|
2264
2920
|
|
|
2265
2921
|
3. Confirm the collection reappears at \`/collections/${slug}\`.
|
|
2266
2922
|
|
|
2267
2923
|
- slug: \`${slug}\`
|
|
2268
2924
|
- title: ${schema.title}
|
|
2269
|
-
- dataPath: \`${schema.dataPath}\`
|
|
2925
|
+
- dataPath: \`${schema.dataPath ?? `(dataSource) ${schema.dataSource?.path}`}\`
|
|
2270
2926
|
`;
|
|
2271
2927
|
}
|
|
2272
2928
|
/** Copy one skill copy + the records + RESTORE.md into `archiveDir`. */
|
|
@@ -2489,14 +3145,15 @@ async function recordIssuesWarning(collection, deps) {
|
|
|
2489
3145
|
return `${issues.length} record file(s) have data problems and are missing from this result. Fix each (Read → correct → Write):\n${lines}`;
|
|
2490
3146
|
}
|
|
2491
3147
|
async function loadRequestedItems(collection, ids, deps) {
|
|
3148
|
+
const store = storeFor(collection, { workspaceRoot: deps.workspaceRoot });
|
|
2492
3149
|
if (!ids) return {
|
|
2493
|
-
items: await
|
|
3150
|
+
items: await store.list(),
|
|
2494
3151
|
missing: []
|
|
2495
3152
|
};
|
|
2496
3153
|
const items = [];
|
|
2497
3154
|
const missing = [];
|
|
2498
3155
|
for (const recordId of ids) {
|
|
2499
|
-
const item = await
|
|
3156
|
+
const item = await store.read(recordId).catch(() => null);
|
|
2500
3157
|
if (item) items.push(item);
|
|
2501
3158
|
else missing.push(recordId);
|
|
2502
3159
|
}
|
|
@@ -2600,7 +3257,35 @@ async function putOneItem(collection, record, mode, deps) {
|
|
|
2600
3257
|
if (result.kind === "conflict") return reject(itemId, `'${itemId}' already exists — mode "create" refuses overwrite; use "upsert" to update it`);
|
|
2601
3258
|
return reject(itemId, "write refused: the collection's data dir escapes the workspace");
|
|
2602
3259
|
}
|
|
3260
|
+
/** Aggregation over a collection via the structured query DSL
|
|
3261
|
+
* (`core/queryZ.ts`) — the paved road for counts / sums / group-bys
|
|
3262
|
+
* that a row listing can't answer honestly. Two engines behind one
|
|
3263
|
+
* shape: a dataSource collection queries its CSV natively through the
|
|
3264
|
+
* store (`store.query`, uncapped whole-file scan); a file-backed
|
|
3265
|
+
* collection aggregates its ENRICHED records (computed fields —
|
|
3266
|
+
* `derived` / `rollup` / `toggle` — are real columns) through the same
|
|
3267
|
+
* compiled SQL over a temp JSONL (`runQueryOverRows`). */
|
|
3268
|
+
async function handleQueryItems(collection, queryArg, deps) {
|
|
3269
|
+
const parsed = CollectionQueryZ.safeParse(queryArg);
|
|
3270
|
+
if (!parsed.success) return `manageCollection: \`query\` rejected — fix and retry:\n${parsed.error.issues.slice(0, 20).map((issue) => `- ${issue.path.map(String).join(".") || "(root)"}: ${defangForPrompt(issue.message)}`).join("\n")}`;
|
|
3271
|
+
const store = storeFor(collection, { workspaceRoot: deps.workspaceRoot });
|
|
3272
|
+
if (store.query) {
|
|
3273
|
+
const rows = await store.query(parsed.data);
|
|
3274
|
+
return JSON.stringify({
|
|
3275
|
+
collection: collection.slug,
|
|
3276
|
+
count: rows.length,
|
|
3277
|
+
rows
|
|
3278
|
+
});
|
|
3279
|
+
}
|
|
3280
|
+
const rows = await runQueryOverRows(await enrichItems(collection, await store.list(), deps), parsed.data);
|
|
3281
|
+
return JSON.stringify({
|
|
3282
|
+
collection: collection.slug,
|
|
3283
|
+
count: rows.length,
|
|
3284
|
+
rows
|
|
3285
|
+
});
|
|
3286
|
+
}
|
|
2603
3287
|
async function handlePutItems(collection, args, deps) {
|
|
3288
|
+
if (!collectionWritable(collection)) return `manageCollection: ${readOnlyRefusal(collection.slug)} (its records are the rows of '${collection.schema.dataSource?.path}'; edit that file to change the data).`;
|
|
2604
3289
|
const written = [];
|
|
2605
3290
|
const rejected = [];
|
|
2606
3291
|
for (const record of args.items) {
|
|
@@ -2699,7 +3384,8 @@ function schemaDiscoveryGate(schema, base) {
|
|
|
2699
3384
|
const primaryField = schema.fields[schema.primaryKey];
|
|
2700
3385
|
if (!primaryField) return `primaryKey '${schema.primaryKey}' is not one of the declared fields`;
|
|
2701
3386
|
if (primaryField.primary !== true) return `the primaryKey field '${schema.primaryKey}' must be flagged \`primary: true\``;
|
|
2702
|
-
if (resolveDataDir(schema.dataPath, base) === null) return `dataPath '${schema.dataPath}' escapes the workspace`;
|
|
3387
|
+
if (schema.dataPath !== void 0 && resolveDataDir(schema.dataPath, base) === null) return `dataPath '${schema.dataPath}' escapes the workspace`;
|
|
3388
|
+
if (schema.dataSource !== void 0 && resolveDataDir(schema.dataSource.path, base) === null) return `dataSource.path '${schema.dataSource.path}' escapes the workspace`;
|
|
2703
3389
|
return null;
|
|
2704
3390
|
}
|
|
2705
3391
|
/** Validate a schema against CollectionSchemaZ and, on success, persist it.
|
|
@@ -2721,7 +3407,7 @@ async function handlePutSchema(slug, schemaArg, deps) {
|
|
|
2721
3407
|
written: true
|
|
2722
3408
|
});
|
|
2723
3409
|
}
|
|
2724
|
-
var MANAGE_COLLECTION_PROMPT = "Use `manageCollection` instead of raw Read/Write/Edit when working with a collection's records OR its schema (raw file I/O stays available as the escape hatch). Before authoring or changing a collection's `schema.json`, call `schemaDocs` to load the field/DSL reference, then read with `getSchema` and write with `putSchema` — `putSchema` validates the whole schema before writing and returns actionable errors instead of silently failing discovery's validation. `getItems` is the only way to see computed values — `derived` fields (e.g. a portfolio's value), `toggle` projections, and `embed` records are host-computed and never present in the stored JSON files. On large collections pass `ids` and/or `fields` to keep the result small. For a question that spans collections (\"which clients have unpaid invoices?\"), start with `getOntology`: it lists every collection with its primaryKey, record count, and outbound `ref`/`embed` relations, so you know which collections to join before reading any records. `putItems` validates every row against the schema before writing (required fields, enum values, primaryKey = record id) and returns `{ written, rejected }`; fix each rejected row using its `problem` text and retry just those rows. Never include computed fields in a row you write. To update a few fields of an existing record, use `mode: \"merge\"` with a partial row ({ id, <changed fields> }) — the default upsert replaces the WHOLE record, so a partial upsert would silently erase every optional field it omits.";
|
|
3410
|
+
var MANAGE_COLLECTION_PROMPT = "Use `manageCollection` instead of raw Read/Write/Edit when working with a collection's records OR its schema (raw file I/O stays available as the escape hatch). Before authoring or changing a collection's `schema.json`, call `schemaDocs` to load the field/DSL reference, then read with `getSchema` and write with `putSchema` — `putSchema` validates the whole schema before writing and returns actionable errors instead of silently failing discovery's validation. `getItems` is the only way to see computed values — `derived` fields (e.g. a portfolio's value), `toggle` projections, and `embed` records are host-computed and never present in the stored JSON files. On large collections pass `ids` and/or `fields` to keep the result small. For a question that spans collections (\"which clients have unpaid invoices?\"), start with `getOntology`: it lists every collection with its primaryKey, record count, and outbound `ref`/`embed` relations, so you know which collections to join before reading any records. `putItems` validates every row against the schema before writing (required fields, enum values, primaryKey = record id) and returns `{ written, rejected }`; fix each rejected row using its `problem` text and retry just those rows. Never include computed fields in a row you write. To update a few fields of an existing record, use `mode: \"merge\"` with a partial row ({ id, <changed fields> }) — the default upsert replaces the WHOLE record, so a partial upsert would silently erase every optional field it omits. Answer aggregation questions (counts, sums, averages, group-bys) with `queryItems` on ANY collection — on a dataSource (CSV) collection it scans the whole file (getItems is row-capped, so aggregates computed from its output can be silently wrong on large files); on a file-backed collection it aggregates the enriched records, so computed fields (derived/rollup/toggle) are queryable columns.";
|
|
2725
3411
|
/** Validate getItems' optional `ids`/`fields` args, then delegate. */
|
|
2726
3412
|
async function dispatchGetItems(collection, args, deps) {
|
|
2727
3413
|
const ids = optionalStringArray(args.ids, "ids");
|
|
@@ -2742,76 +3428,83 @@ async function manageCollectionHandler(deps, args) {
|
|
|
2742
3428
|
if (!slug) return "manageCollection: `slug` is required (the collection's slug).";
|
|
2743
3429
|
if (action === "getSchema") return handleGetSchema(slug, deps);
|
|
2744
3430
|
if (action === "putSchema") return handlePutSchema(slug, args.schema, deps);
|
|
2745
|
-
if (action !== "getItems" && action !== "putItems") return "manageCollection: `action` must be \"getItems\", \"putItems\", \"getOntology\", \"schemaDocs\", \"getSchema\", or \"putSchema\".";
|
|
3431
|
+
if (action !== "getItems" && action !== "putItems" && action !== "queryItems") return "manageCollection: `action` must be \"getItems\", \"putItems\", \"queryItems\", \"getOntology\", \"schemaDocs\", \"getSchema\", or \"putSchema\".";
|
|
2746
3432
|
const collection = await loadCollection(slug, deps);
|
|
2747
3433
|
if (!collection) return unknownCollection(slug);
|
|
2748
3434
|
if (action === "getItems") return dispatchGetItems(collection, args, deps);
|
|
3435
|
+
if (action === "queryItems") return handleQueryItems(collection, args.query, deps);
|
|
2749
3436
|
const parsed = parsePutItems(args, slug);
|
|
2750
3437
|
if (typeof parsed === "string") return parsed;
|
|
2751
3438
|
return handlePutItems(collection, parsed, deps);
|
|
2752
3439
|
}
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
3440
|
+
var MANAGE_COLLECTION_DEFINITION = {
|
|
3441
|
+
name: "manageCollection",
|
|
3442
|
+
description: "Read and write a schema-driven collection through the host — both its records and its structure. getItems returns records WITH computed values (derived formulas, toggles, embeds) the stored JSON files don't contain; putItems validates each row against the schema before writing. getOntology maps the whole workspace: every collection with its record count and outbound ref/embed relations — call it first for cross-collection questions. schemaDocs returns the collection-authoring reference; getSchema/putSchema read and validate-then-write the collection's schema.json. Prefer it over raw file I/O on collections.",
|
|
3443
|
+
inputSchema: {
|
|
3444
|
+
type: "object",
|
|
3445
|
+
properties: {
|
|
3446
|
+
action: {
|
|
3447
|
+
type: "string",
|
|
3448
|
+
enum: [
|
|
3449
|
+
"getItems",
|
|
3450
|
+
"putItems",
|
|
3451
|
+
"queryItems",
|
|
3452
|
+
"getOntology",
|
|
3453
|
+
"schemaDocs",
|
|
3454
|
+
"getSchema",
|
|
3455
|
+
"putSchema"
|
|
3456
|
+
],
|
|
3457
|
+
description: "What to do."
|
|
3458
|
+
},
|
|
3459
|
+
slug: {
|
|
3460
|
+
type: "string",
|
|
3461
|
+
description: "The collection's slug (its directory name, e.g. `stock-quotes`). Required for everything except schemaDocs and getOntology."
|
|
3462
|
+
},
|
|
3463
|
+
ids: {
|
|
3464
|
+
type: "array",
|
|
3465
|
+
items: { type: "string" },
|
|
3466
|
+
description: "getItems: only these record ids (primary-key values). Omit for all records."
|
|
3467
|
+
},
|
|
3468
|
+
fields: {
|
|
3469
|
+
type: "array",
|
|
3470
|
+
items: { type: "string" },
|
|
3471
|
+
description: "getItems: only these fields per record (the primary key is always included). Omit for all fields. Use on large collections."
|
|
3472
|
+
},
|
|
3473
|
+
items: {
|
|
3474
|
+
type: "array",
|
|
3475
|
+
items: { type: "object" },
|
|
3476
|
+
description: "putItems: the record objects to store. Each must carry the schema's primaryKey value (it doubles as the filename)."
|
|
3477
|
+
},
|
|
3478
|
+
mode: {
|
|
3479
|
+
type: "string",
|
|
3480
|
+
enum: [
|
|
3481
|
+
"upsert",
|
|
3482
|
+
"create",
|
|
3483
|
+
"merge"
|
|
3484
|
+
],
|
|
3485
|
+
description: "putItems: \"upsert\" (default) replaces existing records WHOLE; \"create\" rejects rows whose id already exists; \"merge\" updates only the fields a row carries, keeping the rest of the existing record (rejects unknown ids). Use \"merge\" when changing a few fields."
|
|
3486
|
+
},
|
|
3487
|
+
schema: {
|
|
3488
|
+
type: "object",
|
|
3489
|
+
description: "putSchema: the full collection schema object (same shape as schema.json — title, icon, dataPath, primaryKey, fields, …). Call getSchema first for the current one, and schemaDocs for the field DSL."
|
|
3490
|
+
},
|
|
3491
|
+
query: {
|
|
2759
3492
|
type: "object",
|
|
2760
|
-
|
|
2761
|
-
action: {
|
|
2762
|
-
type: "string",
|
|
2763
|
-
enum: [
|
|
2764
|
-
"getItems",
|
|
2765
|
-
"putItems",
|
|
2766
|
-
"getOntology",
|
|
2767
|
-
"schemaDocs",
|
|
2768
|
-
"getSchema",
|
|
2769
|
-
"putSchema"
|
|
2770
|
-
],
|
|
2771
|
-
description: "What to do."
|
|
2772
|
-
},
|
|
2773
|
-
slug: {
|
|
2774
|
-
type: "string",
|
|
2775
|
-
description: "The collection's slug (its directory name, e.g. `stock-quotes`). Required for everything except schemaDocs and getOntology."
|
|
2776
|
-
},
|
|
2777
|
-
ids: {
|
|
2778
|
-
type: "array",
|
|
2779
|
-
items: { type: "string" },
|
|
2780
|
-
description: "getItems: only these record ids (primary-key values). Omit for all records."
|
|
2781
|
-
},
|
|
2782
|
-
fields: {
|
|
2783
|
-
type: "array",
|
|
2784
|
-
items: { type: "string" },
|
|
2785
|
-
description: "getItems: only these fields per record (the primary key is always included). Omit for all fields. Use on large collections."
|
|
2786
|
-
},
|
|
2787
|
-
items: {
|
|
2788
|
-
type: "array",
|
|
2789
|
-
items: { type: "object" },
|
|
2790
|
-
description: "putItems: the record objects to store. Each must carry the schema's primaryKey value (it doubles as the filename)."
|
|
2791
|
-
},
|
|
2792
|
-
mode: {
|
|
2793
|
-
type: "string",
|
|
2794
|
-
enum: [
|
|
2795
|
-
"upsert",
|
|
2796
|
-
"create",
|
|
2797
|
-
"merge"
|
|
2798
|
-
],
|
|
2799
|
-
description: "putItems: \"upsert\" (default) replaces existing records WHOLE; \"create\" rejects rows whose id already exists; \"merge\" updates only the fields a row carries, keeping the rest of the existing record (rejects unknown ids). Use \"merge\" when changing a few fields."
|
|
2800
|
-
},
|
|
2801
|
-
schema: {
|
|
2802
|
-
type: "object",
|
|
2803
|
-
description: "putSchema: the full collection schema object (same shape as schema.json — title, icon, dataPath, primaryKey, fields, …). Call getSchema first for the current one, and schemaDocs for the field DSL."
|
|
2804
|
-
}
|
|
2805
|
-
},
|
|
2806
|
-
required: ["action"]
|
|
3493
|
+
description: "queryItems: a structured aggregation query — `{ groupBy?: [\"col\"], aggregates?: { alias: { op: \"count\"|\"sum\"|\"avg\"|\"min\"|\"max\", column? } }, where?: [{ field, op, value }], orderBy?: [{ field, dir? }], limit? }`. At least one of groupBy/aggregates. On a dataSource (CSV) collection it scans the WHOLE file uncapped (unlike getItems); on a file-backed collection it aggregates the ENRICHED records, so computed fields (derived/rollup/toggle) are queryable columns. Use it for counts / sums / averages / group-bys instead of arithmetic over getItems output."
|
|
2807
3494
|
}
|
|
2808
3495
|
},
|
|
3496
|
+
required: ["action"]
|
|
3497
|
+
}
|
|
3498
|
+
};
|
|
3499
|
+
function makeManageCollectionTool(deps = {}) {
|
|
3500
|
+
return {
|
|
3501
|
+
definition: MANAGE_COLLECTION_DEFINITION,
|
|
2809
3502
|
alwaysActive: true,
|
|
2810
3503
|
prompt: MANAGE_COLLECTION_PROMPT,
|
|
2811
3504
|
handler: (args) => manageCollectionHandler(deps, args)
|
|
2812
3505
|
};
|
|
2813
3506
|
}
|
|
2814
3507
|
//#endregion
|
|
2815
|
-
export {
|
|
3508
|
+
export { isRegularFile as $, validateCollectionRecords as A, decodeCsvRecordId as B, discoverCollections as C, CollectionSchemaZ as D, toSummary as E, collectionWritable as F, compileJsonlQuery as G, encodeCsvRecordId as H, readOnlyRefusal as I, MAX_QUERY_ROWS as J, CollectionQueryZ as K, storeFor as L, compileRecordZ as M, recordFieldProblem as N, applyMutateAction as O, runQueryOverRows as P, generateItemId as Q, MAX_CSV_ROWS as R, acceptParsedSchema as S, setCollectionChangePublisher as St, toDetail as T, normalizeCsvValue as U, dedupeByRecordId as V, compileCsvQuery as W, buildCollectionActionSeedPrompt as X, buildActionSeedPrompt as Y, deleteItem as Z, errorMessage as _, collectionsRegistriesConfigPath as _t, deleteCollection as a, readSkillTemplate as at, buildWorkspaceOntology as b, log as bt, computeSuccessor as c, writeFileAtomic as ct, isTriggerDue as d, isContainedInWorkspace as dt, listItems as et, maybeSpawnSuccessor as f, itemFilePath as ft, ONE_SECOND_MS as g, safeSlugName as gt, successorId as h, safeRecordId as ht, deleteCustomView as i, readItem as it, validateRecordObject as j, firstMutateParamProblem as k, daysInMonth as l, SCHEMA_FILE$1 as lt, resolveEvery as m, resolveTemplatePath as mt, MAX_UNSELECTIVE_ITEMS as n, readCustomViewHtml as nt, deleteCollectionRefusalMessage as o, resolveCreateItemId as ot, parseCivil as p, resolveDataDir as pt, DEFAULT_QUERY_ROWS as q, makeManageCollectionTool as r, readCustomViewI18n as rt, advanceTriggerDate as s, writeItem as st, MAX_SCHEMA_ISSUES as t, promptPathsFor as tt, formatCivil as u, isContainedInRoot as ut, computeCollectionIcon as v, configureCollectionHost as vt, loadCollection as w, schemaRelations as x, publishCollectionChange as xt, enrichItems as y, getWorkspaceRoot as yt, csvRowToItem as z };
|
|
2816
3509
|
|
|
2817
|
-
//# sourceMappingURL=server-
|
|
3510
|
+
//# sourceMappingURL=server-D2ibbixF.js.map
|