@mdbase-dev/connect-dev 0.1.0-beta.26 → 0.1.0-beta.28

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
@@ -47,14 +47,27 @@ import { defineTypePack } from "@mdbase-dev/connect-dev";
47
47
  const provision = defineTypePack({
48
48
  id: "example.tasks",
49
49
  version: "1.0.0",
50
- provides: [{ id: "example.task", version: "1.0.0" }],
51
50
  resources: [
52
- { kind: "contract", source: "_contracts/example.task.md", document: contractDocument },
53
- { kind: "type", source: "_types/task.md", document: typeDocument }
51
+ {
52
+ kind: "contract",
53
+ mode: "managed",
54
+ source: "_contracts/example.task.md",
55
+ document: contractDocument
56
+ },
57
+ {
58
+ kind: "type",
59
+ mode: "seed",
60
+ source: "_types/task.md",
61
+ document: typeDocument
62
+ }
54
63
  ]
55
64
  });
56
65
  ```
57
66
 
67
+ The helper validates each contract document and derives the exact semantic
68
+ `provides` descriptors. Managed resources evolve with the pack; seed resources
69
+ become collection-owned after their initial creation.
70
+
58
71
  Native applications may add a reverse-domain private-use callback scheme that
59
72
  matches the v1 manifest's application ID, such as
60
73
  `example.tasks.desktop://auth/mdbase/callback` for
package/dist/index.d.ts CHANGED
@@ -45,6 +45,8 @@ export declare class DataContractDefinitionError extends Error {
45
45
  }
46
46
  export interface TypePackResourceInput {
47
47
  kind: "contract" | "type" | "schema";
48
+ /** Managed resources evolve with the pack; seed resources become user-owned after creation. */
49
+ mode: "managed" | "seed";
48
50
  source: string;
49
51
  /** Collection-relative install location. Defaults to `source`. */
50
52
  target?: string;
@@ -57,10 +59,6 @@ export interface TypePackDefinition {
57
59
  name?: string;
58
60
  description?: string;
59
61
  resources: TypePackResourceInput[];
60
- provides: Array<{
61
- id: string;
62
- version: string;
63
- }>;
64
62
  }
65
63
  /**
66
64
  * Build a complete, digest-pinned type-pack provision from readable source
@@ -68,6 +66,10 @@ export interface TypePackDefinition {
68
66
  * developers to calculate SHA-256 values by hand.
69
67
  */
70
68
  export declare function defineTypePack(definition: TypePackDefinition): TypePackProvision;
69
+ /** Compute the portable semantic digest of one validated, fully resolved contract. */
70
+ export declare function dataContractDigest(contract: Record<string, unknown>): string;
71
+ export declare class DataContractDigestError extends Error {
72
+ }
71
73
  export declare class TypePackDefinitionError extends Error {
72
74
  readonly issues: ValidationIssue[];
73
75
  constructor(issues: ValidationIssue[]);
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { Ajv2020 } from "ajv/dist/2020.js";
3
+ import { parseDocument } from "yaml";
3
4
  import { MdbaseCollectionClient, connectError } from "@mdbase-dev/connect";
4
5
  import { isNativeRedirectUri } from "@mdbase-dev/connect-protocol";
5
6
  import appManifestSchema from "@mdbase-dev/connect-protocol/schemas/mdbase-app.schema.json" with { type: "json" };
@@ -29,6 +30,9 @@ export function validateAppManifest(value, options = {}) {
29
30
  const originResult = validateManifestOrigins(value, options.allowLocal === true);
30
31
  if (!originResult.valid)
31
32
  return originResult;
33
+ const capabilityResult = validateCapabilityRequirements(value);
34
+ if (!capabilityResult.valid)
35
+ return capabilityResult;
32
36
  return validateProvisionRequirements(value);
33
37
  }
34
38
  export function validateDataContract(value) {
@@ -60,6 +64,18 @@ export class DataContractDefinitionError extends Error {
60
64
  * developers to calculate SHA-256 values by hand.
61
65
  */
62
66
  export function defineTypePack(definition) {
67
+ const provides = definition.resources
68
+ .map((resource, index) => ({ resource, index }))
69
+ .filter(({ resource }) => resource.kind === "contract")
70
+ .map(({ resource, index }) => {
71
+ if (resource.mode !== "managed") {
72
+ throw new TypePackDefinitionError([definitionIssue(`/resources/${index}/mode`, "contract_resource_mode", "contract resources must be managed so their declared version and digest can evolve with the pack")]);
73
+ }
74
+ return exactContractReferenceFromDocument(resource.document, index);
75
+ })
76
+ .sort((left, right) => left.id.localeCompare(right.id)
77
+ || left.version.localeCompare(right.version)
78
+ || left.digest.localeCompare(right.digest));
63
79
  const provision = {
64
80
  manifest: {
65
81
  kind: "mdbase.type-pack",
@@ -67,21 +83,75 @@ export function defineTypePack(definition) {
67
83
  version: definition.version,
68
84
  ...(definition.name ? { name: definition.name } : {}),
69
85
  ...(definition.description ? { description: definition.description } : {}),
70
- resources: definition.resources.map(({ kind, source, target = source, document }) => ({
86
+ resources: definition.resources.map(({ kind, mode, source, target = source, document }) => ({
71
87
  kind,
88
+ mode,
72
89
  source,
73
90
  target,
74
91
  digest: `sha256:${createHash("sha256").update(document).digest("hex")}`
75
92
  }))
76
93
  },
77
94
  resources: definition.resources.map(({ source, document }) => ({ source, document })),
78
- provides: definition.provides.map((contract) => ({ ...contract }))
95
+ provides
79
96
  };
80
97
  const result = validateProtocolValue(provision, "typePackProvision");
81
98
  if (!result.valid)
82
99
  throw new TypePackDefinitionError(result.issues);
83
100
  return provision;
84
101
  }
102
+ function exactContractReferenceFromDocument(document, resourceIndex) {
103
+ const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u.exec(document)?.[1];
104
+ if (frontmatter === undefined) {
105
+ throw new TypePackDefinitionError([definitionIssue(`/resources/${resourceIndex}/document`, "contract_frontmatter", "contract resources must contain YAML frontmatter")]);
106
+ }
107
+ const parsed = parseDocument(frontmatter, { uniqueKeys: true });
108
+ if (parsed.errors.length > 0) {
109
+ throw new TypePackDefinitionError([definitionIssue(`/resources/${resourceIndex}/document`, "contract_frontmatter", `contract frontmatter is invalid: ${parsed.errors[0]?.message ?? "unknown YAML error"}`)]);
110
+ }
111
+ const contract = parsed.toJS({ maxAliasCount: 0 });
112
+ const validation = validateDataContract(contract);
113
+ if (!validation.valid)
114
+ throw new DataContractDefinitionError(validation.issues);
115
+ const value = contract;
116
+ return {
117
+ id: String(value.id),
118
+ version: String(value.version),
119
+ digest: dataContractDigest(value)
120
+ };
121
+ }
122
+ /** Compute the portable semantic digest of one validated, fully resolved contract. */
123
+ export function dataContractDigest(contract) {
124
+ const validation = validateDataContract(contract);
125
+ if (!validation.valid)
126
+ throw new DataContractDefinitionError(validation.issues);
127
+ const contractType = String(contract.contract_type);
128
+ const portable = {
129
+ kind: contract.kind,
130
+ contract_type: contract.contract_type,
131
+ id: contract.id,
132
+ version: contract.version
133
+ };
134
+ const schemaFields = contractType === "record"
135
+ ? ["record_schema", "binding_schema"]
136
+ : contractType === "event"
137
+ ? ["data_schema", "source_schema"]
138
+ : ["input_schema", "output_schema", "error_schema", "provider_schema"];
139
+ for (const field of schemaFields) {
140
+ const wrapper = contract[field];
141
+ if (wrapper === undefined)
142
+ continue;
143
+ if (!isRecord(wrapper) || !("value" in wrapper)) {
144
+ throw new DataContractDigestError(`${field} must be resolved to an inline JSON Schema before calculating a contract digest.`);
145
+ }
146
+ portable[field] = structuredClone(wrapper.value);
147
+ }
148
+ if (contractType === "action" && contract.behavior !== undefined) {
149
+ portable.behavior = structuredClone(contract.behavior);
150
+ }
151
+ return `sha256:${createHash("sha256").update(canonicalJson(portable)).digest("hex")}`;
152
+ }
153
+ export class DataContractDigestError extends Error {
154
+ }
85
155
  export class TypePackDefinitionError extends Error {
86
156
  issues;
87
157
  constructor(issues) {
@@ -475,6 +545,63 @@ function validateProvisionRequirements(value) {
475
545
  }
476
546
  return { valid: true, issues: [] };
477
547
  }
548
+ function validateCapabilityRequirements(value) {
549
+ const manifest = asObject(value);
550
+ const requirements = asObject(manifest.requirements);
551
+ const capabilities = asObject(requirements.capabilities);
552
+ if (Object.keys(capabilities).length === 0)
553
+ return { valid: true, issues: [] };
554
+ const required = Array.isArray(capabilities.required)
555
+ ? capabilities.required.map(String)
556
+ : [];
557
+ const optional = Array.isArray(capabilities.optional)
558
+ ? capabilities.optional.map(String)
559
+ : [];
560
+ if (new Set(required).size !== required.length) {
561
+ return semanticIssue("/requirements/capabilities/required", "must not contain duplicates");
562
+ }
563
+ if (new Set(optional).size !== optional.length) {
564
+ return semanticIssue("/requirements/capabilities/optional", "must not contain duplicates");
565
+ }
566
+ const overlap = optional.find((capability) => required.includes(capability));
567
+ if (overlap) {
568
+ return semanticIssue("/requirements/capabilities/optional", `must not repeat required capability ${overlap}`);
569
+ }
570
+ const declared = new Set([...required, ...optional]);
571
+ const provisions = asObject(manifest.provisions);
572
+ if (Array.isArray(requirements.contracts)
573
+ && requirements.contracts.length > 0
574
+ && requirements.access !== "full_collection"
575
+ && !required.includes("definitions.contracts.current")) {
576
+ return semanticIssue("/requirements/capabilities/required", "must require definitions.contracts.current for contract-scoped requirements");
577
+ }
578
+ if (declared.has("definitions.type-pack.apply")) {
579
+ if (requirements.access !== "full_collection") {
580
+ return semanticIssue("/requirements/access", "must be full_collection for definitions.type-pack.apply");
581
+ }
582
+ if (!required.includes("definitions.type-pack.apply")) {
583
+ return semanticIssue("/requirements/capabilities/required", "must require definitions.type-pack.apply when it is declared");
584
+ }
585
+ if (!Array.isArray(provisions.type_packs) || provisions.type_packs.length === 0) {
586
+ return semanticIssue("/provisions/type_packs", "must contain a pack for definitions.type-pack.apply");
587
+ }
588
+ }
589
+ if (declared.has("notifications.background-delivery")
590
+ && !Array.isArray(asObject(manifest.notifications).criteria)) {
591
+ return semanticIssue("/notifications/criteria", "must be declared for notifications.background-delivery");
592
+ }
593
+ const fileRequirement = asObject(requirements.files);
594
+ const fileActions = Array.isArray(fileRequirement.actions)
595
+ ? new Set(fileRequirement.actions.map(String))
596
+ : new Set();
597
+ for (const action of ["list", "read", "add", "replace", "move", "delete"]) {
598
+ const capability = `files.${action}`;
599
+ if (declared.has(capability) !== fileActions.has(action)) {
600
+ return semanticIssue("/requirements/capabilities", `${capability} and requirements.files.actions.${action} must be declared together`);
601
+ }
602
+ }
603
+ return { valid: true, issues: [] };
604
+ }
478
605
  function localManifestSchemaCandidate(value) {
479
606
  const candidate = clone(value);
480
607
  const object = asObject(candidate);
@@ -511,13 +638,30 @@ function semanticIssue(path, message) {
511
638
  issues: [{ path, keyword: "semantic", message, params: {} }]
512
639
  };
513
640
  }
641
+ function definitionIssue(path, keyword, message) {
642
+ return { path, keyword, message, params: {} };
643
+ }
644
+ function isRecord(value) {
645
+ return value !== null && typeof value === "object" && !Array.isArray(value);
646
+ }
647
+ function canonicalJson(value) {
648
+ if (value === null || typeof value !== "object")
649
+ return JSON.stringify(value);
650
+ if (Array.isArray(value))
651
+ return `[${value.map(canonicalJson).join(",")}]`;
652
+ return `{${Object.entries(value)
653
+ .filter(([, child]) => child !== undefined)
654
+ .sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
655
+ .map(([key, child]) => `${JSON.stringify(key)}:${canonicalJson(child)}`)
656
+ .join(",")}}`;
657
+ }
514
658
  function sandboxDescription(value = {}) {
515
659
  return {
516
660
  protocol_version: 1,
517
661
  collection_id: value.collection_id ?? "01900000-0000-7000-8000-000000000001",
518
662
  display_name: value.display_name ?? "Developer sandbox",
519
663
  spec_version: value.spec_version ?? "0.3.0",
520
- operations: value.operations ?? ["describe", "changes", "read", "query", "list_views", "execute_view", "read_view_source", "validate", "create", "update", "delete", "rename", "create_view_source", "update_view_source", "delete_view_source", "read_type", "create_type", "update_type", "install_type_pack"],
664
+ operations: value.operations ?? ["describe", "changes", "read", "query", "list_views", "execute_view", "read_view_source", "validate", "create", "update", "delete", "rename", "create_view_source", "update_view_source", "delete_view_source", "read_type", "create_type", "update_type", "assess_type_pack", "apply_type_pack"],
521
665
  change_cursor: value.change_cursor ?? 0,
522
666
  types: clone(value.types ?? []),
523
667
  contracts: clone(value.contracts ?? [])
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mdbase-dev/connect-dev",
3
- "version": "0.1.0-beta.26",
3
+ "version": "0.1.0-beta.28",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,8 +25,9 @@
25
25
  ],
26
26
  "dependencies": {
27
27
  "ajv": "^8.20.0",
28
- "@mdbase-dev/connect": "0.1.0-beta.26",
29
- "@mdbase-dev/connect-protocol": "0.1.0-beta.26"
28
+ "yaml": "^2.9.0",
29
+ "@mdbase-dev/connect": "0.1.0-beta.28",
30
+ "@mdbase-dev/connect-protocol": "0.1.0-beta.28"
30
31
  },
31
32
  "devDependencies": {
32
33
  "@types/node": "^24.0.0",