You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 72b26ed
Browse filesBrowse the repository at this point in the historyBrowse files
Part of #15213. This is stage S1 only, the import verb behind the
`unmanaged` install mode; the card stays open for S2 (the `installModes`
manifest key with its install door, `--install-mode` and the
`installMode` body key), S3 (the authoring docs page) and S4 (the
objectui marketplace choice).
Clause-②: yes (widening)
The widening: two new published schemas in `@objectstack/spec/api`
(`ImportUnmanagedPackageRequestSchema`, parsed by its producer, and
`ImportUnmanagedPackageResponseSchema`), and two new error codes
registered to `@objectstack/metadata-protocol`. Nothing existing is
removed or narrowed. Changeset:
`.changeset/15213-unmanaged-import-verb.md` (`minor` on both packages).
## What it does
`ObjectStackProtocolImplementation.importUnmanagedPackage({ manifest,
targetPackageId?, targetName? })`
(`packages/metadata-protocol/src/protocol.ts`, beside
`duplicatePackage`) copies every metadata item of ONE package body into
a NEW writable base in the environment ledger, through `saveMetaItem`,
the save door a Studio edit takes. Every copied row is owned by the
base, carries `organization_id` NULL, is served `_provenance: 'org'`,
and the next save edits it. Nothing is registered under the source id.
This is the part of a managed install that the manifest service does,
and nothing more.
The rulings it carries, as built:
- **The spelling is `unmanaged`** (ruling 6108509158, letter B): the
codes are `UNMANAGED_INSTALL_*` and the write face is
`'package-unmanaged-import'`.
- **Q2: the refusal reads the REQUESTED posture.**
`resolveTenancyPosture()`; an unrecognised value is read as walled
(fail-closed, as the protocol's other posture gate does). `group` /
`isolated` answer `UNMANAGED_INSTALL_WALLED_POSTURE` / 409, and
`details.posture` names it.
- **Q3: a NEW base whose id differs from the source.** `targetPackageId`
defaults to the source id plus `-copy` (the Studio duplicate default).
The source namespace is kept. The base is installed scope-less
(writable) through `installPackage`, never best-effort. A target id
naming an installed package is refused (`RESOURCE_CONFLICT` / 409):
never into an existing base.
- **Q5, the door half: runtime-uncreatable types and code are refused
before any write.** The type verdict is the save door's own predicate
(`isRuntimeCreateAllowed`, registry-only); `OS_METADATA_WRITABLE` is not
consulted, and a test sets it and shows it does not open `job`. Code
keys (`functions`, `onEnable`, `plugins`, `devPlugins`, `runtimeModule`,
and a plugin artifact's `main` / `runtime` / `packaging` / `integrity`),
extensions of another package's items (`objectExtensions`,
`picklistExtensions`, `navigationContributions`, `contributes`) and the
artifact envelope (`packages`, a nested `manifest`) are refused whole
with `UNMANAGED_INSTALL_UNCOPYABLE` / 422, naming each key. The
authoring half (`defineStack` / `os validate`) rides S2 with the
manifest key.
- **Q7: every judgeable refusal before the base exists; any write
failure deletes the base.** Order: organization-scoped request, the
published request declaration (a key it does not declare is a 400),
posture, the body (uncopyable keys, item names against the save door's
grammar, duplicate names, the target id), the ADR-0087 D1 handshake, an
existing target, an owned namespace (a managed install of the same
package included), conversion to today's protocol (`INVALID_METADATA` /
422, `FLOW_CONVERSION_CONFLICT` / 409), and collisions with environment
rows or items a managed package ships (`RESOURCE_CONFLICT` / 409,
`details.collisions`). A write that fails afterwards runs
`deletePackage(base)`, which removes its rows and drops its tables, and
the thrown error keeps the failed item's own `code` and `status`, names
the item, and says what the rollback did (`details.rolledBack`). A
rollback that cannot finish is logged once at `error` with the
consequence and the fix.
### What is copied, and what is left to the door
The pure half lives in
`packages/metadata-protocol/src/package-unmanaged-install.ts`. Copied:
the items of every manifest collection (`PLURAL_TO_SINGULAR`, the one
map the artifact loader and the authoring lint read) plus the assembled
`viewItems`, written in registry `loadOrder`, a view container named by
the object it binds (the artifact loader's naming). Every other
top-level key is classified in `UNMANAGED_IMPORT_KEY_DISPOSITIONS`, and
a test holds that table total against `ManifestSchema` (tombstoned keys
excepted) and the stack definition in both directions, so a key either
schema gains reddens a test instead of being refused with no reason on
record. An unclassified key is refused, never skipped.
Two classes are deliberately not this verb's, because a managed install
leaves them out of the manifest service too: seed `data` and
`translations` are side effects install-local's step 5 applies in either
mode (seeds are data, never metadata items), and deployment
configuration (`i18n`, `requires`, `datasourceMapping`, …) is read by a
stack's boot, by no install. S2's door owns their unmanaged fate; see
the acceptance notes.
## Measured before building (the dispatch's mechanism assumptions)
- `external-import` is NOT this import's face: it serves
`@objectstack/service-datasource`'s "Import as Object" route, which
relays `400 EXTERNAL_IMPORT_ERROR`. One new face was needed, as the
stage plan said, and both face switches state it
(`destructiveChangeRemedy` gets its own 409 clause;
`specValidationFindings` keeps the full-prose default, documented).
- No request schema for duplicate exists anywhere in spec (zero hits for
`DuplicatePackageRequest`). The request schema here is new, and ENFORCED
rather than transcribed: the verb parses through it. The module header
of `package-lifecycle.zod.ts` says so.
- `installModes` / `installMode`: still zero hits in `packages/` and
`examples/`; nothing of S2 was needed, and no `runtime` file is edited.
## Tests
`packages/metadata-protocol/src/package-unmanaged-install.test.ts`, 40
cases. The verb runs against a stub engine that keeps rows, so the REAL
`saveMetaItem`, `installPackage` and `deletePackage` run; its write
verbs route through the producer's dispatch predicates, and its `find`
honours `limit`. Refusals are asserted as the envelope
`resolveThrownHttpError` answers (`code` and `status`), never a bare
throw. The stage-0 pins, each built:
- every item copied with `package_id` = the base and `organization_id`
NULL, nothing under the source id; the response parses through
`ImportUnmanagedPackageResponseSchema`;
- the registry is handed every copied item stamped `_provenance: 'org'`
(a view's registry item; an object's registered schema, under the base
id);
- a walled requested posture (`isolated`, `group`, an unrecognised
value) is refused 409 with ZERO writes and no package; the lit control
on `single` imports;
- a collision with an environment row, with a managed package's item, an
owned namespace and an existing target are each refused before the base
is created;
- a mid-copy failure (a spec-invalid second item) is thrown as the save
door's own `INVALID_METADATA` / 422 and leaves no package, no row, and
the object's table dropped;
- a follow-up save edits a copied view AND a copied object.
### Ablations (one-shot, not kept as tests)
Each leg mutated through `scripts/ablation-replace.mjs` (the anchor must
hit exactly once, the write is verified on disk by blob hash, the
restore is proven against `HEAD` by blob hash and an empty `git diff
HEAD`), inside a wrapper carrying an EXIT/INT/TERM restore trap. The
suite imports `src` directly (no `dist` leg). Direction predicted before
running: red for all four; observed red for all four, and only where
predicted. Run at `b5cfa28ee0`; the two mutated files are byte-identical
at this PR's head (`protocol.ts` blob `432f1d7841`,
`package-unmanaged-install.ts` blob `352e1b7afc`).
| Leg | Mutation | Red |
|---|---|---|
| A1 | the posture refusal's `throw` removed | the 3 walled-posture
cases (37 of 40 still green) |
| A2 | the rollback's `deletePackage` call replaced by an empty outcome
| the mid-copy failure case (39 of 40 green) |
| A3 | the collision refusal disabled | the environment-row and
managed-item collision cases (38 of 40 green) |
| A4 | the runtime-uncreatable refusal disabled | the 5 per-type cases
and the hatch case (34 of 40 green) |
### Gates (local, targeted; CI runs the full farm)
- **Derived on this diff** with `node scripts/pm/dispatch-gates.mjs
--commands --repo objectstack-ai/objectstack` at `d15160d108`: 122
commands, a strict superset of the 84 derived at dispatch (the additions
come from the generated docs pages and the gate ledger this diff
touches).
- **All 122 run at `d15160d108`**, each exit code captured to disk
before any pipe: **122 × exit 0**. `--ran` reconciliation over the
recorded codes: 122 derived, 122 run, 0 NOT-MEASURED, 0 UNRUN.
- An earlier run of the union at `b5cfa28ee0` found two real reds, both
in this PR's test double, both fixed in `d15160d108`:
`check:objectql-double-limit` (the stub's `find` ignored `limit`) and
`check:engine-double-contract` (a new pinned double needs its ledger
rows, written by the gate's own `--write`: 3 rows added, 0 lost). Three
gates that run read a missing `dist` there (exit 3, not measured); after
building the `objectql` and `client-react` closures all three measured
green above.
- `@objectstack/metadata-protocol`: `typecheck` (`tsc --noEmit`) green
at `d15160d108`; the full suite at the post-merge tree `2a26dc7eec`: 225
files passed, 3 skipped, 28,168 tests passed, with one red file, the
`lookupArtifactItem(` call-site census, fixed in `b5cfa28ee0` (the new
call removed) and re-run green with this PR's suite and both face
inventories (4 files, 100 tests).
- `@objectstack/spec`: the full `typecheck` chain (`tsc --noEmit`,
scripts, test-typecheck) green; every suite under `src/api/` (51 files,
1,595 tests) green; `check:generated` reports all 14 generated artifacts
current after the merge.
- Not run locally, left to CI: the repo-wide `pnpm lint`, the full test
farm, and the merge queue's rebuild. The derivation flags its tree as
behind `origin/main` by at least 5 commits, two of which changed gate
scripts (`scripts/check-route-envelope.mjs`,
`scripts/cross-package-test-inputs.mjs`); CI reads the merged state.
Size: 1,566 changed lines (1,553 added, 13 deleted, generated included),
17 files.
## Acceptance notes
Out of scope for S1, recorded for the carrier named; none is filed.
- **S2 owns seeds and translations for the unmanaged mode.** The verb
leaves `data` and `translations` to the door, as install-local's step 5
applies them for a managed install. One difference S2 has to decide on:
a managed install rehydrates its translation bundles from its ledger
entry at each boot; an unmanaged install writes no ledger entry, so
bundles loaded once into the i18n service would not come back after a
restart unless S2 carries them some other way. Carrier: S2.
- **`installed_from` has no carrier yet.** The card body asks for an
informational `installed_from`; the stage-0 report proposed the
server-stated write source. The verb does not state a `source` on its
`saveMetaItem` calls, because the save door reads any stated `source` as
a stored-row rewrite and skips the authored-write rule for it. The
answer names the source id and version, which the door can record.
Carrier: S2.
- **Spec validity is judged at the write, then rolled back, not
preflighted.** The stage plan's preflight list does not include a
per-item schema parse, and a second copy of the save door's gates in the
verb would be a parallel judge. A spec-invalid item therefore creates
the base and deletes it again (pinned). If the seat reads Q7's "every
judgeable case" as including schema validity, the preflight can parse
each item through `getMetadataTypeSchema` before the base. Carrier: the
seat, at review.
- **Hooks with a function-name `handler` and no `body`** are not judged
by the verb: install-local's step 1d refuses them before any mode fork,
using the runtime binder this package cannot import. Carrier: S2
(install-local runs 1d before any point where S2 can branch on the
mode).
- **The managed-collision detail does not name the owning package.** It
says `shipped by a managed package`: naming it would add a
`lookupArtifactItem(` call site, which
`protocol.lookup-artifact-item-call-sites.test.ts` records with a
disposition, in a file outside this stage's surface. The namespace
refusal does name its owners. Carrier: none.
- **Unmeasured:** an item carrying an authored `protection` lock is
copied as written, so it may stay locked in the environment ledger; and
an exported package whose `viewItems` carry an edited view-container
expansion may be refused by the save door's container-expansion
predicate on import (then rolled back). Carrier: S2's acceptance run on
`examples/app-crm`.
- **Observation, no card:** `convertStoredItem`'s legacy-shape notice
says "stored row … re-save it" when the import converts an artifact
item; the wording describes a stored row, not an import. Carrier: none.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
`importUnmanagedPackage` copies one package into the environment ledger, editable from then on — the import behind the `unmanaged` install mode (ADR-0131 D6)
7
+
8
+
Clause-②: yes (widening)
9
+
10
+
A package installs one of two ways. Managed: the package is the authority, it upgrades, and nothing it ships may be edited. Unmanaged: every metadata item is copied once into this environment, is fully editable, and never follows an upgrade. This release adds the import; no door calls it yet, and no manifest key declares the mode yet.
11
+
12
+
-**The verb** (`@objectstack/metadata-protocol`). `ObjectStackProtocolImplementation.importUnmanagedPackage({ manifest, targetPackageId?, targetName? })` writes every metadata item of ONE package body into a NEW writable base through the metadata save door: each row is owned by the base, carries no organization, is served as environment-authored (`_provenance: 'org'`), and the next save edits it. The base's id differs from the source (`targetPackageId`, defaulting to `<id>-copy`), it keeps the source namespace so every object name stays valid, and nothing is registered under the source id. The answer names the source, the base, its namespace and every item copied, in write order.
13
+
-**All or nothing.** Every refusal that can be judged is raised before the base exists, so a refused import writes nothing: a deployment whose requested tenancy posture walls organizations (`group`, `isolated`, or an unrecognised value); a body carrying what cannot be copied; a malformed name or an unusable target id; a protocol-incompatible package; a target id that names an installed package, or a namespace another package owns (a managed install of the same package included); an item that does not convert to this protocol; a name an environment row or a managed package already holds. A write that fails afterwards deletes the base — its rows and its tables — and the failure is thrown with the item's own code and status, naming the item and what the rollback did.
14
+
-**What is copied, and what is refused.** The items of every manifest collection and the assembled `viewItems` are copied. A body carrying a runtime-uncreatable type (`jobs`, `agents`, `apis`, `capabilities`, `picklists`), code (`functions`, `onEnable`, `plugins`, a plugin artifact's `main` / `runtime` / `packaging` / `integrity`), extensions of another package's items (`objectExtensions`, `picklistExtensions`, `navigationContributions`, `contributes`) or an artifact envelope (`packages`) is refused whole; `OS_METADATA_WRITABLE` does not open a type for it. Seed `data` and `translations` are left to the install door, which applies them after the copy as it does for a managed install; deployment configuration (`i18n`, `requires`, `datasourceMapping`, …) is not part of a package install in either mode.
15
+
-**Two new error codes** (`@objectstack/spec`, registered to `@objectstack/metadata-protocol`). `UNMANAGED_INSTALL_WALLED_POSTURE` (409) and `UNMANAGED_INSTALL_UNCOPYABLE` (422). Both mean the package can be installed managed instead. A collision, an existing target or an owned namespace answers the standard `RESOURCE_CONFLICT` (409).
16
+
-**Two new schemas** (`@objectstack/spec/api`). `ImportUnmanagedPackageRequestSchema` is parsed by the verb, so a request key it does not declare is refused with `400`. `ImportUnmanagedPackageResponseSchema` declares the answer.
17
+
18
+
Nothing existing changes: no schema, door or code is removed or narrowed.
Copy file name to clipboardExpand all lines: content/docs/references/api/contract.mdx
+3-1Lines changed: 3 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data);
28
28
29
29
| Property | Type | Required | Description |
30
30
| :--- | :--- | :--- | :--- |
31
-
|**code**|`Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +321 more>`| ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
31
+
|**code**|`Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +323 more>`| ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
32
32
|**declaredCode**|`string`| optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) |
|**userMessage**|`string`| optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. |
@@ -353,6 +353,8 @@ const result = ApiErrorSchema.parse(data);
const result =DiscardPackageDraftsResponseSchema.parse(data);
@@ -112,6 +117,42 @@ const result = DiscardPackageDraftsResponseSchema.parse(data);
112
117
|**error**|`string`| ✅ | Why copying it failed. |
113
118
114
119
120
+
---
121
+
122
+
## ImportUnmanagedPackageRequest
123
+
124
+
### Properties
125
+
126
+
| Property | Type | Required | Description |
127
+
| :--- | :--- | :--- | :--- |
128
+
|**manifest**|`Record<string, any>`| ✅ | The package to import: ONE package's assembled body, flattened — its manifest fields beside its metadata collections (`objects`, `views`, …), the shape the install door hands the manifest service. An artifact carrying `packages` is refused: import each package body on its own. |
129
+
|**targetPackageId**|`string`| optional | Id of the NEW writable base the items are written into, in reverse-domain form. It must differ from the source id and name no installed package. Defaults to `<manifest.id>-copy`. |
130
+
|**targetName**|`string`| optional | Display name of the new base. Defaults to the source package's `name`. |
131
+
132
+
133
+
---
134
+
135
+
## ImportUnmanagedPackageResponse
136
+
137
+
### Properties
138
+
139
+
| Property | Type | Required | Description |
140
+
| :--- | :--- | :--- | :--- |
141
+
|**sourcePackageId**|`string`| ✅ | The id of the package the items were copied from. |
142
+
|**sourceVersion**|`string`| optional | The version the imported body declared, when it declared one. |
143
+
|**targetPackageId**|`string`| ✅ | The new writable base that owns every copied item. Nothing is registered under the source id. |
144
+
|**namespace**|`string`| ✅ | The base's namespace — the SOURCE package's, kept so every copied object name stays valid. |
145
+
|**copiedCount**|`number`| ✅ | How many items were copied. |
146
+
|**copied**|`{ type: string; name: string }[]`| ✅ | Every item that was copied, in the order it was written. |
|[Automation Protocol](/docs/references/automation)| 13 | 70 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. |
26
26
|[Data Protocol](/docs/references/data)| 29 | 176 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. |
0 commit comments