@effected/schemastore 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -61,6 +61,7 @@ import { defineConfig } from "@effected/schemastore";
61
61
  import { OutputSchemaIdentity, ReleaseOutput } from "./src/schema/output.js";
62
62
 
63
63
  export default defineConfig({
64
+ name: "silk-release-action",
64
65
  outputDir: "schemas",
65
66
  schemas: {
66
67
  [OutputSchemaIdentity.name]: { schema: ReleaseOutput, hosted: OutputSchemaIdentity },
@@ -130,7 +131,7 @@ const program = SchemaPipeline.run(targets).pipe(
130
131
  ## What the library owns
131
132
 
132
133
  - `HostedSchema` — a schema's hosted identity (`github`, `schemastore`, `custom`), deriving `$id`, the catalog URL and the file name for the current or any advertised version.
133
- - `defineConfig` — the `schemastore.config.ts` contract: validated with one `Schema.Struct` per level (a typo'd key is named, every issue on an entry reported at once), every path and URL derived from the entry key and its identity, frozen labels resolved, a branded result the CLI recognises.
134
+ - `defineConfig` — the `schemastore.config.ts` contract: validated with one `Schema.Struct` per level (a typo'd key is named, every issue on an entry reported at once), a required config `name` (the base name of its catalog slice), every path and URL derived from the entry key and its identity, frozen labels resolved, a branded result the CLI recognises.
134
135
  - `StoreDocument` — assembly: `fromSchema` / `fromSchemaResult`, the `draft07` constructor for hand-built documents, the flat `toJson()` publication shape, `serializeResult()`, `DRAFT_07_META_SCHEMA`.
135
136
  - `KeywordFamilies` — the one registry of declared non-standard keyword families (the vscode five, `x-taplo`, `x-tombi-`, `x-intellij-`, and the house `x-ai-` machine-annotation namespace). Anything outside it fails `fromSchema` with `UndeclaredAnnotationKeyError`; nothing is silently dropped.
136
137
  - `SchemaVersioning` / `SchemaVersion` — one-to-three-component version labels, `Order`, `latest`, `isPinned`, `next`, and the `fileName` / `schemaUrl` / `catalogUrls` derivations.
@@ -50,11 +50,12 @@ const EntryInput = Schema.Struct({
50
50
  rootAnnotations: Schema.optionalKey(OptionsInput)
51
51
  });
52
52
  const ConfigInput = Schema.Struct({
53
+ name: Schema.String,
53
54
  outputDir: Schema.NonEmptyString,
54
55
  baseUrl: Schema.optionalKey(Schema.String),
55
56
  drift: Schema.optionalKey(DriftToleranceInput),
56
57
  onDrift: Schema.optionalKey(OnDriftInput),
57
- catalogPath: Schema.optionalKey(Schema.NonEmptyString),
58
+ catalogDir: Schema.optionalKey(Schema.NonEmptyString),
58
59
  schemas: Schema.Record(Schema.String, Schema.Unknown)
59
60
  });
60
61
  const DECODE_OPTIONS = {
@@ -156,6 +157,12 @@ const normalizePath = (raw) => {
156
157
  }
157
158
  return `${absolute ? "/" : ""}${out.join("/")}`;
158
159
  };
160
+ const assertCatalogDirHoldsOnlySlices = (catalogDir, outputDir, documents) => {
161
+ const dir = normalizePath(catalogDir);
162
+ if (dir === normalizePath(`${catalogDir}/../catalog.json`)) fail(`catalogDir "${catalogDir}" must not be the merged catalog's path (catalog.json in its parent)`);
163
+ if (dir === normalizePath(outputDir)) fail(`catalogDir "${catalogDir}" must not be outputDir: every *.json file in it is read as a catalog slice`);
164
+ for (const document of documents) if (normalizePath(`${document}/..`) === dir) fail(`output path "${document}" sits in catalogDir "${catalogDir}", which holds only catalog slices`);
165
+ };
159
166
  const assertUniquePaths = (paths) => {
160
167
  const seen = /* @__PURE__ */ new Set();
161
168
  for (const p of paths) {
@@ -189,18 +196,26 @@ const assertUniquePaths = (paths) => {
189
196
  * {@link HostedSchema}, or one built from `baseUrl`/`versions`/`current`/
190
197
  * `layout` and the config default — is validated by `HostedSchema` itself
191
198
  * (a `hosted` entry must be keyed by `hosted.name` and must not spell those
192
- * four fields beside it); a schema key must be a simple file base name; a
193
- * `catalog` is required under `baseUrl: "schemastore"`; an empty `schemas`
194
- * record is rejected; and an output path (a target, a frozen file, or the
195
- * catalog path) declared twice is rejected after a lexical normalisation
196
- * (`./`, `..`, trailing `/`) — the CLI's loader re-checks on the resolved
197
- * absolute paths. Branding the result lets a loader recognise a config
199
+ * four fields beside it); the config `name` is required (a missing one
200
+ * fails `defineConfig: name is required — …`, naming what it is for), and
201
+ * it and every schema key must be simple file base names; a `catalog` is required under
202
+ * `baseUrl: "schemastore"`; an empty `schemas` record is rejected; a
203
+ * `catalogDir` that is `outputDir`, that is the merged catalog's own path,
204
+ * or that a derived document sits directly in, is rejected (every `*.json`
205
+ * file there is read as a catalog slice); and an output path (a target, a frozen file, this config's
206
+ * catalog slice `<catalogDir>/<name>.json`, or the merged catalog
207
+ * `catalog.json` in `catalogDir`'s parent) declared twice is rejected after
208
+ * a lexical normalisation (`./`, `..`, trailing `/`) — the CLI's loader
209
+ * re-checks on the resolved absolute paths. Branding the result lets a loader recognise a config
198
210
  * module's default export via {@link isSchemastoreConfig}.
199
211
  *
200
212
  * @public
201
213
  */
202
214
  const defineConfig = (input) => {
203
- const config = decodeOrThrow(ConfigInput, withoutUndefined(input), "");
215
+ const raw = withoutUndefined(input);
216
+ if (Predicate.isObject(raw) && !Array.isArray(raw) && !Object.hasOwn(raw, "name")) return fail("name is required — the base name of this config's catalog slice (<catalogDir>/<name>.json)");
217
+ const config = decodeOrThrow(ConfigInput, raw, "");
218
+ if (!SchemaVersioning.isSimpleName(config.name)) return fail(`name "${config.name}" must be a simple file base name (no separators, no whitespace)`);
204
219
  const outputDir = trimSlashes(config.outputDir);
205
220
  if (Object.keys(config.schemas).length === 0) return fail("at least one schema is required");
206
221
  const defaults = {
@@ -208,13 +223,20 @@ const defineConfig = (input) => {
208
223
  drift: config.drift ?? DriftPolicy.defaults.policy
209
224
  };
210
225
  const schemas = Object.entries(config.schemas).map(([name, entry]) => resolveEntry(name, entry, defaults, outputDir));
211
- const catalogPath = config.catalogPath ?? `${outputDir}/catalog.json`;
212
- assertUniquePaths([...schemas.flatMap((s) => [s.target.path, ...s.frozen.map((f) => f.path)]), catalogPath]);
226
+ const catalogDir = config.catalogDir !== void 0 ? trimSlashes(config.catalogDir) : `${outputDir}/catalogs`;
227
+ const documents = schemas.flatMap((s) => [s.target.path, ...s.frozen.map((f) => f.path)]);
228
+ assertCatalogDirHoldsOnlySlices(catalogDir, outputDir, documents);
229
+ assertUniquePaths([
230
+ ...documents,
231
+ `${catalogDir}/${config.name}.json`,
232
+ `${catalogDir}/../catalog.json`
233
+ ]);
213
234
  return {
214
235
  [ConfigBrand]: true,
236
+ name: config.name,
215
237
  outputDir,
216
238
  onDrift: config.onDrift ?? DriftPolicy.defaults.onDrift,
217
- catalogPath,
239
+ catalogDir,
218
240
  schemas
219
241
  };
220
242
  };
package/StoreDocument.js CHANGED
@@ -128,6 +128,52 @@ const restoreDefsRefs = (node, depth) => {
128
128
  }
129
129
  return node;
130
130
  };
131
+ const SCHEMA_KEYWORDS = /* @__PURE__ */ new Set([
132
+ "items",
133
+ "additionalItems",
134
+ "contains",
135
+ "additionalProperties",
136
+ "propertyNames",
137
+ "not",
138
+ "if",
139
+ "then",
140
+ "else"
141
+ ]);
142
+ const SCHEMA_ARRAY_KEYWORDS = /* @__PURE__ */ new Set([
143
+ "allOf",
144
+ "anyOf",
145
+ "oneOf"
146
+ ]);
147
+ const SCHEMA_MAP_KEYWORDS = /* @__PURE__ */ new Set([
148
+ "properties",
149
+ "patternProperties",
150
+ "definitions",
151
+ "$defs",
152
+ "dependencies"
153
+ ]);
154
+ const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
155
+ const collapseSchemaMap = (node, depth) => {
156
+ if (!isPlainObject(node)) return node;
157
+ const out = Object.create(null);
158
+ for (const [key, value] of Object.entries(node)) out[key] = isPlainObject(value) ? collapseUniformTuples(value, depth + 1) : value;
159
+ return out;
160
+ };
161
+ const collapseUniformTuples = (node, depth) => {
162
+ if (depth >= 256) throw new RewriteDepthExceeded();
163
+ if (!isPlainObject(node)) return node;
164
+ const out = Object.create(null);
165
+ for (const [key, value] of Object.entries(node)) if (SCHEMA_KEYWORDS.has(key)) out[key] = Array.isArray(value) ? value.map((item) => collapseUniformTuples(item, depth + 1)) : collapseUniformTuples(value, depth + 1);
166
+ else if (SCHEMA_ARRAY_KEYWORDS.has(key) && Array.isArray(value)) out[key] = value.map((item) => collapseUniformTuples(item, depth + 1));
167
+ else if (SCHEMA_MAP_KEYWORDS.has(key)) out[key] = collapseSchemaMap(value, depth + 1);
168
+ else out[key] = value;
169
+ const tuple = out.items;
170
+ const rest = out.additionalItems;
171
+ if (Array.isArray(tuple) && tuple.length > 0 && isPlainObject(rest) && tuple.every((element) => CanonicalJson.equals(element, rest))) {
172
+ out.items = rest;
173
+ delete out.additionalItems;
174
+ }
175
+ return out;
176
+ };
131
177
  /**
132
178
  * A SchemaStore-shaped Draft-07 JSON Schema document assembled from an
133
179
  * Effect Schema source: `$schema` (the Draft-07 meta-schema) + `$id` + the
@@ -137,9 +183,11 @@ const restoreDefsRefs = (node, depth) => {
137
183
  * `Schema.toJsonSchemaDocument` (Draft 2020-12), core's
138
184
  * `JsonSchema.toDocumentDraft07` lowering, the `#/definitions` →
139
185
  * `#/$defs` `$ref` rewrite the lowering makes necessary — so every `$ref`
140
- * in a built document already resolves against the `$defs` pool — and the
141
- * gate that holds the document's non-standard surface to the declared
142
- * keyword families ({@link KeywordFamilies}). The package owns assembly and
186
+ * in a built document already resolves against the `$defs` pool — the
187
+ * uniform-tuple collapse that keeps open-ended `NonEmptyArray`-shaped
188
+ * arrays publishable through the strict ajv gate (#818), and the gate that
189
+ * holds the document's non-standard surface to the declared keyword
190
+ * families ({@link KeywordFamilies}). The package owns assembly and
143
191
  * publication shape, not a JSON Schema engine.
144
192
  *
145
193
  * Annotated declared-family keys survive into the built document because
@@ -148,7 +196,12 @@ const restoreDefsRefs = (node, depth) => {
148
196
  * (2020-12 `prefixItems[i]` → Draft-07 `items[i]`, trailing `items` →
149
197
  * `additionalItems`). This package therefore does no re-grafting of its
150
198
  * own; it only declines to rewrite `$ref`-shaped strings *inside* those
151
- * opaque payloads.
199
+ * opaque payloads. The one structural normalization it does own is the
200
+ * uniform-tuple collapse: when every tuple element is content-equal to the
201
+ * open rest schema — always true for `NonEmptyArray` — the tuple is
202
+ * rewritten to a single-schema `items` with core's `minItems` untouched,
203
+ * the shape ajv's strictTuples rule accepts; a heterogeneous head keeps the
204
+ * tuple form.
152
205
  *
153
206
  * @public
154
207
  */
@@ -220,9 +273,9 @@ var StoreDocument = class StoreDocument extends Schema.Class("StoreDocument")({
220
273
  keys: [...undeclared].sort()
221
274
  }));
222
275
  const lowered = JsonSchema.toDocumentDraft07(document);
223
- const root = restoreDefsRefs(lowered.schema, 0);
276
+ const root = collapseUniformTuples(restoreDefsRefs(lowered.schema, 0), 0);
224
277
  const defs = Object.create(null);
225
- for (const [name, definition] of Object.entries(lowered.definitions)) defs[name] = restoreDefsRefs(definition, 1);
278
+ for (const [name, definition] of Object.entries(lowered.definitions)) defs[name] = collapseUniformTuples(restoreDefsRefs(definition, 1), 1);
226
279
  if (options.rootAnnotations !== void 0) applyRootAnnotations(root, defs, options.rootAnnotations);
227
280
  return Result.succeed(StoreDocument.make({
228
281
  $schema: DRAFT_07_META_SCHEMA,
package/index.d.ts CHANGED
@@ -339,9 +339,11 @@ declare const StoreDocument_base: Schema.Class<StoreDocument, Schema.Struct<{
339
339
  * `Schema.toJsonSchemaDocument` (Draft 2020-12), core's
340
340
  * `JsonSchema.toDocumentDraft07` lowering, the `#/definitions` →
341
341
  * `#/$defs` `$ref` rewrite the lowering makes necessary — so every `$ref`
342
- * in a built document already resolves against the `$defs` pool — and the
343
- * gate that holds the document's non-standard surface to the declared
344
- * keyword families ({@link KeywordFamilies}). The package owns assembly and
342
+ * in a built document already resolves against the `$defs` pool — the
343
+ * uniform-tuple collapse that keeps open-ended `NonEmptyArray`-shaped
344
+ * arrays publishable through the strict ajv gate (#818), and the gate that
345
+ * holds the document's non-standard surface to the declared keyword
346
+ * families ({@link KeywordFamilies}). The package owns assembly and
345
347
  * publication shape, not a JSON Schema engine.
346
348
  *
347
349
  * Annotated declared-family keys survive into the built document because
@@ -350,7 +352,12 @@ declare const StoreDocument_base: Schema.Class<StoreDocument, Schema.Struct<{
350
352
  * (2020-12 `prefixItems[i]` → Draft-07 `items[i]`, trailing `items` →
351
353
  * `additionalItems`). This package therefore does no re-grafting of its
352
354
  * own; it only declines to rewrite `$ref`-shaped strings *inside* those
353
- * opaque payloads.
355
+ * opaque payloads. The one structural normalization it does own is the
356
+ * uniform-tuple collapse: when every tuple element is content-equal to the
357
+ * open rest schema — always true for `NonEmptyArray` — the tuple is
358
+ * rewritten to a single-schema `items` with core's `minItems` untouched,
359
+ * the shape ajv's strictTuples rule accepts; a heterogeneous head keeps the
360
+ * tuple form.
354
361
  *
355
362
  * @public
356
363
  */
@@ -1883,6 +1890,15 @@ interface SchemaEntryInput {
1883
1890
  * @public
1884
1891
  */
1885
1892
  interface SchemastoreConfigInput {
1893
+ /**
1894
+ * This config's identity: the base name of the catalog slice it owns
1895
+ * (`<catalogDir>/<name>.json`). Configs sharing a `catalogDir` must each
1896
+ * carry a distinct one — distinct case-insensitively, since on a
1897
+ * case-insensitive volume `docs` and `Docs` name one file and overwrite
1898
+ * each other; it must be a simple file base name (no separators, no
1899
+ * whitespace).
1900
+ */
1901
+ readonly name: string;
1886
1902
  /** The directory every derived `path` is written under; a trailing slash is trimmed. */
1887
1903
  readonly outputDir: string;
1888
1904
  /** The default {@link SchemaEntryInput.baseUrl} for an entry that declares none. */
@@ -1891,8 +1907,20 @@ interface SchemastoreConfigInput {
1891
1907
  readonly drift?: DriftTolerance;
1892
1908
  /** What a build does when it finds drift. Defaults to `"error"`. */
1893
1909
  readonly onDrift?: OnDrift;
1894
- /** Where the assembled catalog is written. Defaults to `<outputDir>/catalog.json`. */
1895
- readonly catalogPath?: string;
1910
+ /**
1911
+ * The directory of catalog slices, one per config: this config writes its
1912
+ * own entries to `<catalogDir>/<name>.json`, and the CLI maintains the
1913
+ * merged `catalog.json` — every slice in the directory, united — in the
1914
+ * directory's parent. Defaults to `<outputDir>/catalogs`, so
1915
+ * the merged catalog lands at `<outputDir>/catalog.json`. Every `*.json`
1916
+ * file in it is read as a slice, so it holds nothing else, must not be
1917
+ * `outputDir` itself, and must not be the merged catalog's own path.
1918
+ * Every config that shares a merged catalog must share the same
1919
+ * `catalogDir`, under a `name` unique case-insensitively among them: two sibling directories (`schemas/catalogs`,
1920
+ * `schemas/more`) both merge into `schemas/catalog.json` from different
1921
+ * slice sets and overwrite each other — no single config can detect it.
1922
+ */
1923
+ readonly catalogDir?: string;
1896
1924
  /**
1897
1925
  * The schemas to derive, keyed by file base name — the key IS the
1898
1926
  * `name` every derived path and URL is built from, so it must be a
@@ -1945,12 +1973,14 @@ interface ResolvedSchema {
1945
1973
  */
1946
1974
  interface SchemastoreConfig {
1947
1975
  readonly [ConfigBrand]: true;
1976
+ /** This config's identity: the base name of its catalog slice. */
1977
+ readonly name: string;
1948
1978
  /** The directory every derived `path` is written under, trailing slash trimmed. */
1949
1979
  readonly outputDir: string;
1950
1980
  /** What a build does when it finds drift. */
1951
1981
  readonly onDrift: OnDrift;
1952
- /** Where the assembled catalog is written. */
1953
- readonly catalogPath: string;
1982
+ /** The directory of catalog slices; this config's own is `<catalogDir>/<name>.json`. */
1983
+ readonly catalogDir: string;
1954
1984
  /** Every schema, resolved. */
1955
1985
  readonly schemas: ReadonlyArray<ResolvedSchema>;
1956
1986
  }
@@ -1979,12 +2009,17 @@ interface SchemastoreConfig {
1979
2009
  * {@link HostedSchema}, or one built from `baseUrl`/`versions`/`current`/
1980
2010
  * `layout` and the config default — is validated by `HostedSchema` itself
1981
2011
  * (a `hosted` entry must be keyed by `hosted.name` and must not spell those
1982
- * four fields beside it); a schema key must be a simple file base name; a
1983
- * `catalog` is required under `baseUrl: "schemastore"`; an empty `schemas`
1984
- * record is rejected; and an output path (a target, a frozen file, or the
1985
- * catalog path) declared twice is rejected after a lexical normalisation
1986
- * (`./`, `..`, trailing `/`) — the CLI's loader re-checks on the resolved
1987
- * absolute paths. Branding the result lets a loader recognise a config
2012
+ * four fields beside it); the config `name` is required (a missing one
2013
+ * fails `defineConfig: name is required — …`, naming what it is for), and
2014
+ * it and every schema key must be simple file base names; a `catalog` is required under
2015
+ * `baseUrl: "schemastore"`; an empty `schemas` record is rejected; a
2016
+ * `catalogDir` that is `outputDir`, that is the merged catalog's own path,
2017
+ * or that a derived document sits directly in, is rejected (every `*.json`
2018
+ * file there is read as a catalog slice); and an output path (a target, a frozen file, this config's
2019
+ * catalog slice `<catalogDir>/<name>.json`, or the merged catalog
2020
+ * `catalog.json` in `catalogDir`'s parent) declared twice is rejected after
2021
+ * a lexical normalisation (`./`, `..`, trailing `/`) — the CLI's loader
2022
+ * re-checks on the resolved absolute paths. Branding the result lets a loader recognise a config
1988
2023
  * module's default export via {@link isSchemastoreConfig}.
1989
2024
  *
1990
2025
  * @public
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effected/schemastore",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "private": false,
5
5
  "description": "Build, validate, version and publish SchemaStore-shaped Draft-07 JSON Schema documents from Effect Schema sources: document assembly, ajv strict-mode validation, structural lints, catalog entries, canonical JSON and a content-comparing emit pipeline.",
6
6
  "keywords": [
@@ -38,7 +38,7 @@
38
38
  "./package.json": "./package.json"
39
39
  },
40
40
  "dependencies": {
41
- "@effected/semver": "^0.10.0"
41
+ "@effected/semver": "^0.10.1"
42
42
  },
43
43
  "peerDependencies": {
44
44
  "effect": "4.0.0-rc.118"