diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 17a97728f0..697a7f11c3 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -4,6 +4,20 @@ Web Console preserves compatible API paths, schema and resource names, action na Visible branding, product-owned assets, icon identifiers, package metadata, and operator documentation use PastureStack. Historical identifiers remain only where they are server data or protocol contracts and must not be mechanically replaced. +Candidate `1.6.172` adds opt-in first delivery of fields whose actual Schema +declares `readOnCreateOnly: true`. Only `edit-apikey` enables it. A nonenumerable +request-private callback delivers the successful create values once to a +detached clone, not to serialized metadata or canonical cache. Matching Store, +generation, API base, opaque generated ID, concrete type and owner are required; +newer subscribe state and nested-resource adoption are preserved. Other +NewOrEdit hook arguments/results and consumers retain their previous contracts. +The save owner clears its own pending delivery on success and failure; rejected +duplicates cannot clear another save's lock or values. Compatibility revision 6 +is a new archive with the same dependency graph. Source/package checks pass; +local Chrome tests have not run because of incomplete shared dependencies. +Official tests, publication and packaged native acceptance are pending, not +full-matrix PASS. See the [release note](docs/releases/web-console-1.6.172.md). + Published `1.6.171` confines create-response adoption to ID-less POST/201 and an existing exact-ID/concrete-type canonical model in the same Store, generation and API base. It does not re-import stale scalar or nested create fields over diff --git a/README.md b/README.md index 31f16317e5..8bcc81ca37 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,19 @@ PastureStack is an independent community effort to preserve, audit, and moderniz ## Project status +Candidate `1.6.172` preserves API-key create-only first delivery when a redacted +subscribe model arrives before POST/201. Only the API-key editor opts into a +request-private, Schema-bound delivery to its detached clone; newer canonical +state and nested resources remain intact, and the canonical Store does not need +to retain the secret. Compatibility revision 6 replaces revision 5 without +changing dependency versions or the graph. Source/package checks pass; new +installed-Store and save-owner regressions have been added, including personal +and project stores with 100 deterministic barriers each. Local Chrome tests +have not run because the local shared dependency layout is incomplete. Official +tests, publication and packaged native acceptance remain pending. Historical +HOLDs remain HOLD; the full matrix is INCOMPLETE. See the +[release note](docs/releases/web-console-1.6.172.md). + Published `1.6.171` repairs a shared Store ordering defect: a delayed initial create response could overwrite a newer subscribe model and leave a successfully created local Volume stuck in its initial state. Only ID-less create POST/201 diff --git a/app/components/edit-apikey/component.js b/app/components/edit-apikey/component.js index 1ae75cebf9..2f6c63730f 100644 --- a/app/components/edit-apikey/component.js +++ b/app/components/edit-apikey/component.js @@ -8,6 +8,7 @@ export default ModalBase.extend(NewOrEdit, { model: null, clone: null, justCreated: false, + createOnlyDelivery: true, didReceiveAttrs() { this.set('clone', this.get('originalModel').clone()); @@ -52,7 +53,7 @@ export default ModalBase.extend(NewOrEdit, { { this.setProperties({ justCreated: true, - clone: neu.clone() + clone: this.cloneForCreateDelivery(neu) }); } }, diff --git a/app/mixins/new-or-edit.js b/app/mixins/new-or-edit.js index a219264423..f76214fd15 100644 --- a/app/mixins/new-or-edit.js +++ b/app/mixins/new-or-edit.js @@ -3,6 +3,7 @@ import { alias } from '@ember/object/computed'; import { service } from '@ember/service'; import Mixin from '@ember/object/mixin'; import Resource from 'ember-api-store/models/resource'; +import { bindCreateOnlyDelivery, cloneCreateOnlyDelivery, takeCreateOnlyDelivery } from 'ember-api-store/utils/create-only-delivery'; import Errors from 'ui/utils/errors'; export default Mixin.create({ @@ -11,6 +12,7 @@ export default Mixin.create({ errors: null, saving: false, editing: true, + createOnlyDelivery: false, primaryResource: alias('model'), originalPrimaryResource: alias('originalModel'), @@ -106,6 +108,14 @@ export default Mixin.create({ let finalizerError = null; if ( this._saveOwner === owner ) { + if ( this._createOnlyRequest && this._createOnlyRequest.owner === owner ) { + takeCreateOnlyDelivery(this._createOnlyRequest.options); + this._createOnlyRequest = null; + } + if ( this._createOnlyDelivery && this._createOnlyDelivery.owner === owner ) { + this._createOnlyDelivery.data.fields = null; + this._createOnlyDelivery = null; + } this._saveOwner = null; // A hook that turned saving on and then threw still owns that // state, but a submission which found a pre-existing saving=true @@ -199,11 +209,36 @@ export default Mixin.create({ }, doSave: function(opt) { + const owner = this._saveOwner; + if ( owner && this.get('createOnlyDelivery') ) { + opt = opt || {}; + Object.defineProperty(this, '_createOnlyRequest', { + value: { owner, options: opt }, writable: true, configurable: true, + }); + bindCreateOnlyDelivery(opt, (data) => { + if ( this._saveOwner === owner && !this.isDestroyed && !this.isDestroying ) { + Object.defineProperty(this, '_createOnlyDelivery', { + value: { owner, data }, writable: true, configurable: true, + }); + } else { + data.fields = null; + } + }); + } return this.get('primaryResource').save(opt).then((newData) => { return this.mergeResult(newData); }); }, + cloneForCreateDelivery(resource) { + const pending = this._createOnlyDelivery; + if ( !pending || pending.owner !== this._saveOwner ) { + return resource.clone(); + } + this._createOnlyDelivery = null; + return cloneCreateOnlyDelivery(resource, pending.data); + }, + mergeResult: function(newData) { var original = this.get('originalPrimaryResource'); if ( original ) diff --git a/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json b/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json index c5d74e292d..1d8631e649 100644 --- a/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json +++ b/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json @@ -1,12 +1,12 @@ { "name": "@pasturestack/web-console", - "version": "1.6.171", + "version": "1.6.172", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.171", + "version": "1.6.172", "license": "Apache-2.0", "dependencies": { "sass": "1.103.1" @@ -33,7 +33,7 @@ "core-js": "file:vendor/core-js-compat/core-js-2.6.13-rc16.0.tgz", "d3": "7.9.0", "dagre-d3-es": "7.0.14", - "ember-api-store": "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.5.tgz", + "ember-api-store": "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.6.tgz", "ember-auto-import": "2.13.1", "ember-basic-dropdown": "9.0.0", "ember-cli": "7.2.0", @@ -9051,8 +9051,8 @@ }, "node_modules/ember-api-store": { "version": "2.8.5", - "resolved": "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.5.tgz", - "integrity": "sha512-m+IpOrSUqogl3EP8DqefpDuO/9leZ4Bycge7MLwqYASOz75V/J6ay1bFGaOWd2ckaohymODeOlkVzyVzmWLupw==", + "resolved": "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.6.tgz", + "integrity": "sha512-ojkclvGZq8iObzwSkOBtZxkt9IwZ0GJvmi8CMYFocBwol6YQh9Q4D6g4t6/o/3mNDYmDkULPgdXBVt81xm5BqA==", "dev": true, "license": "Apache-2.0", "dependencies": { diff --git a/docs/releases/web-console-1.6.171.md b/docs/releases/web-console-1.6.171.md index c692d3cc85..ebd1d1d8ee 100644 --- a/docs/releases/web-console-1.6.171.md +++ b/docs/releases/web-console-1.6.171.md @@ -85,7 +85,6 @@ existing 2026-10-10 review boundary. Formal package checks found no affected build-package modules in the static artifact, without making a runtime not-affected VEX or zero-vulnerability claim. No dependency or security-policy change is introduced by this documentation update. - ## Upgrade and rollback Use the separately released Server patch that packages this exact component. diff --git a/docs/releases/web-console-1.6.172.md b/docs/releases/web-console-1.6.172.md new file mode 100644 index 0000000000..f64e5d5f32 --- /dev/null +++ b/docs/releases/web-console-1.6.172.md @@ -0,0 +1,65 @@ +# Web Console 1.6.172 + +Candidate first-delivery repair. Official tests, publication and packaged QA +are separate pending gates; historical failed receipts remain HOLD. + +## Root cause and contract + +Revision 5 correctly adopts a newer cached subscribe model instead of importing +an older POST/201 snapshot. For an API key, subscribe can already contain +`secretValue: null`; discarding the whole 201 also loses its only delivery of the +new secret. The API-key editor therefore cannot show its expected detached +first-delivery clone. Requiring canonical Store secrets to survive later +redacted subscribe updates is neither the fix nor the acceptance contract. + +Engine 333 source `0d94f7d879d314235e582a7f4062914a27b82709` maps auth-overlay +permission `o` to `FieldImpl.readOnCreateOnly` in `AuthOverlayPostProcessor`. +`Field.isReadOnCreateOnly` and its JavaBean implementation export the actual +Schema resource-field property `readOnCreateOnly`. This repair uses that exact +property; it does not invent a schema flag or merge all create-response fields. + +## Minimal change + +An ID-less create request captures only Schema-marked field names in its +nonenumerable internal identity metadata. An opt-in request-private Symbol +callback transports only those successful POST/201 values. API-key editing is +the sole NewOrEdit opt-in. Values are withheld from the canonical import and +consumed once into the editor's detached clone, whose visible/copy value remains +independent of later subscribe redaction. Normal cached state and nested models +are not re-imported from the older response. + +Delivery requires the same Store, generation, API base, exact generated ID, +concrete type and account binding. The existing save owner clears only its own +pending callback and delivery, including failed hooks and synchronous completion +exceptions. Duplicate submissions neither resend create nor clear the owner's +lock or values. Existing hook arguments/results, non-opted-in consumers, backend +permissions, API payloads and request counts remain unchanged. + +API-store compatibility revision 6 is a new archive. Earlier archives and +upstream license text are retained; dependency versions and graph are unchanged. +The source/package checker accepts Windows license line endings while requiring +the unchanged upstream content and exact source/archive equality. + +## Verification boundary + +The ten prior installed-Store ordering regressions remain. Added cases cover +actual API-key doneSaving delivery, redacted subscribe before 201, later +redaction, personal/project Stores (100 deterministic deferred HTTP barriers +each, without sleeps), uncached one-shot delivery, private metadata, store/ +generation/base/type/account mismatch, and synchronous delivery exceptions. +NewOrEdit tests cover delivery cleanup across success, request rejection, +synchronous doneSaving/completion exceptions and rejected duplicate submissions; +ordinary consumers keep their prior options and return value. + +Source/package checks pass. The local Chrome suite has not started because the +local junction-based dependency layout is incomplete; it is not reported as a +test PASS. Exact-source official tests, immutable publication and packaged native +first-delivery acceptance remain pending. Historical HOLDs and the complete +permission/resource/locale matrix are not promoted. + +## Upgrade and rollback + +Use only a separately published Server package containing this exact component. +Retain existing settings, persistent volumes and previous immutable artifacts. +No database migration or backend change is required. This candidate does not +authorize deployment, live retries or a change to authentication settings. diff --git a/package-lock.json b/package-lock.json index c5d74e292d..1d8631e649 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pasturestack/web-console", - "version": "1.6.171", + "version": "1.6.172", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.171", + "version": "1.6.172", "license": "Apache-2.0", "dependencies": { "sass": "1.103.1" @@ -33,7 +33,7 @@ "core-js": "file:vendor/core-js-compat/core-js-2.6.13-rc16.0.tgz", "d3": "7.9.0", "dagre-d3-es": "7.0.14", - "ember-api-store": "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.5.tgz", + "ember-api-store": "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.6.tgz", "ember-auto-import": "2.13.1", "ember-basic-dropdown": "9.0.0", "ember-cli": "7.2.0", @@ -9051,8 +9051,8 @@ }, "node_modules/ember-api-store": { "version": "2.8.5", - "resolved": "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.5.tgz", - "integrity": "sha512-m+IpOrSUqogl3EP8DqefpDuO/9leZ4Bycge7MLwqYASOz75V/J6ay1bFGaOWd2ckaohymODeOlkVzyVzmWLupw==", + "resolved": "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.6.tgz", + "integrity": "sha512-ojkclvGZq8iObzwSkOBtZxkt9IwZ0GJvmi8CMYFocBwol6YQh9Q4D6g4t6/o/3mNDYmDkULPgdXBVt81xm5BqA==", "dev": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 28758ebccb..22851ccf29 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pasturestack/web-console", - "version": "1.6.171", + "version": "1.6.172", "private": true, "description": "PastureStack browser console for the compatible control platform.", "repository": { @@ -76,7 +76,7 @@ "core-js": "file:vendor/core-js-compat/core-js-2.6.13-rc16.0.tgz", "d3": "7.9.0", "dagre-d3-es": "7.0.14", - "ember-api-store": "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.5.tgz", + "ember-api-store": "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.6.tgz", "ember-auto-import": "2.13.1", "ember-basic-dropdown": "9.0.0", "ember-cli": "7.2.0", diff --git a/scripts/check-modernization-blockers b/scripts/check-modernization-blockers index d78bc35a33..40caa864f2 100755 --- a/scripts/check-modernization-blockers +++ b/scripts/check-modernization-blockers @@ -41,8 +41,8 @@ with open('package.json', encoding='utf-8') as f: print(json.load(f).get('version', '')) PY ) -if [[ "$version" != "1.6.171" ]]; then - echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.171" +if [[ "$version" != "1.6.172" ]]; then + echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.172" failures=$((failures + 1)) fi diff --git a/scripts/check-ui-console-workspace b/scripts/check-ui-console-workspace index 60fc4ade01..35d05d92c9 100755 --- a/scripts/check-ui-console-workspace +++ b/scripts/check-ui-console-workspace @@ -141,4 +141,4 @@ if [[ -n ${PASTURESTACK_PRIVATE_MARKER:-} ]] && grep -RInF -- "$PASTURESTACK_PRI fi printf 'UI_CONSOLE_WORKSPACE_OK version=%s persistence=%s cross_tab=%s\n' \ - 1.6.171 browser-session broker-broadcast + 1.6.172 browser-session broker-broadcast diff --git a/scripts/check-ui-critical-high-dependencies b/scripts/check-ui-critical-high-dependencies index 51ff79c629..0b2c02ef9b 100755 --- a/scripts/check-ui-critical-high-dependencies +++ b/scripts/check-ui-critical-high-dependencies @@ -57,7 +57,7 @@ for gate in ("node ./scripts/test-ui-npm-audit.js", "node ./scripts/check-ui-npm for evidence in ("scripts/check-ui-npm-audit.js", "scripts/test-ui-npm-audit.js", "docs/security/npm-vendor-pending.json"): if not Path(evidence).is_file(): fail(f"reviewed live audit evidence is missing: {evidence}") -api_store_compat_spec = "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.5.tgz" +api_store_compat_spec = "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.6.tgz" lock_bytes = lock_path.read_bytes() baseline_bytes = baseline_path.read_bytes() if lock_bytes != baseline_bytes: @@ -70,7 +70,7 @@ if lock_bytes != baseline_bytes: lock = json.loads(lock_bytes) packages = lock.get("packages", {}) root = packages.get("", {}) -if package.get("version") != "1.6.171": +if package.get("version") != "1.6.172": fail(f"unexpected Web Console version: {package.get('version')}") if root.get("version") != package.get("version"): fail(f"lock root version differs: {root.get('version')}") diff --git a/scripts/check-ui-ember-api-store-fetch-upgrade b/scripts/check-ui-ember-api-store-fetch-upgrade index de21614f27..84d18eaa17 100755 --- a/scripts/check-ui-ember-api-store-fetch-upgrade +++ b/scripts/check-ui-ember-api-store-fetch-upgrade @@ -13,7 +13,7 @@ package = json.loads(package_path.read_text(encoding="utf-8")) lock = json.loads(lock_path.read_text(encoding="utf-8")) packages = lock.get("packages", {}) compat_spec = "file:vendor/ember-fetch-compat/ember-fetch-5.1.3-pasturestack.6.tgz" -api_store_compat_spec = "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.5.tgz" +api_store_compat_spec = "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.6.tgz" def fail(message): @@ -110,24 +110,24 @@ with tarfile.open(archive_path, "r:gz") as archive: fail("ember-fetch compatibility source and install archive differ") api_store_compat_dir = repo / "vendor/ember-api-store-compat" -api_store_archive = api_store_compat_dir / "ember-api-store-2.8.5-pasturestack.5.tgz" +api_store_archive = api_store_compat_dir / "ember-api-store-2.8.5-pasturestack.6.tgz" api_store_package_path = api_store_compat_dir / "package.json" api_store_license_path = api_store_compat_dir / "LICENSE" api_store_upstream_path = api_store_compat_dir / "UPSTREAM.md" for required in [api_store_archive, api_store_package_path, api_store_license_path, api_store_upstream_path]: if not required.is_file(): fail(f"reviewed ember-api-store compatibility file missing: {required.relative_to(repo)}") -if hashlib.sha256(api_store_archive.read_bytes()).hexdigest() != "90da9ebdc36a8069629086d011e799691ace8f88c13d9c9df1333c77015a2ab8": +if hashlib.sha256(api_store_archive.read_bytes()).hexdigest() != "58079a9d1cc9539d89999f9fb3ac9f89464a1d79f45bb1c8ada18b8eda545bb6": fail("reviewed ember-api-store compatibility archive hash changed") -if hashlib.sha256(api_store_license_path.read_bytes()).hexdigest() != "0d542e0c8804e39aa7f37eb00da5a762149dc682d7829451287e11b938e94594": +if hashlib.sha256(api_store_license_path.read_bytes().replace(b"\r\n", b"\n")).hexdigest() != "0d542e0c8804e39aa7f37eb00da5a762149dc682d7829451287e11b938e94594": fail("ember-api-store upstream Apache-2.0 license changed") api_store_package = json.loads(api_store_package_path.read_text(encoding="utf-8")) if api_store_package.get("dependencies") != expected_api_store_deps: fail("ember-api-store compatibility source metadata changed") -if api_store_package.get("pasturestackCompatibility", {}).get("revision") != 5: +if api_store_package.get("pasturestackCompatibility", {}).get("revision") != 6: fail("ember-api-store compatibility revision marker is missing") with tarfile.open(api_store_archive, "r:gz") as archive: - for relative in ["package.json", "LICENSE", "UPSTREAM.md", "addon/services/store.js", "addon/mixins/type.js"]: + for relative in ["package.json", "LICENSE", "UPSTREAM.md", "addon/services/store.js", "addon/mixins/type.js", "addon/utils/create-only-delivery.js"]: archived = archive.extractfile(f"package/{relative}") source = api_store_compat_dir / relative if archived is None or archived.read() != source.read_bytes(): @@ -154,7 +154,7 @@ action_dispatch = type_runtime.split(" doAction: function(name, data, opt) {", if "delete opt.createIdentity;" not in action_dispatch: fail("action POST must clear any reused create identity") request_success = api_store_runtime.split(" _requestSuccess(xhr,opt) {", 1)[1].split(" _requestFailed(xhr,opt) {", 1)[0] -for marker in ["delete opt.createIdentity;", "if ( opt.method === 'POST' )", "opt.createIdentity = {", "generation: get(store, 'generation')", "baseUrl: get(store, 'baseUrl')"]: +for marker in ["delete opt.createIdentity;", "if ( opt.method === 'POST' )", "Object.defineProperty(opt, 'createIdentity'", "generation: get(store, 'generation')", "baseUrl: get(store, 'baseUrl')", "fields[key].readOnCreateOnly === true"]: if marker not in create_save: fail(f"create-only save identity marker missing: {marker}") for marker in ["xhr.status === 201 && opt.method === 'POST' && creation", "creation.generation === get(this, 'generation')", "creation.baseUrl === get(this, 'baseUrl')", "cached.get('id') === xhr.body.id", "get(cached, 'store') === this && this.hasRecord(cached)", "response = response || this._typeify(xhr.body);"]: @@ -172,6 +172,10 @@ for marker in [ "204 and errors keep their HTTP semantics without importing a model", "reusing save options cannot carry a create marker into an existing record save", "action POST cannot reuse an old create marker even when the action returns 201", + "apiKey first delivery survives redacted subscribe before 201 and later redaction in personal and project stores, 100 deterministic barriers each", + "uncached API-key create-only delivery is one-shot and schema-bound, with private request metadata", + "create-only delivery rejects changed store, generation, base, concrete type and owner without importing its secret", + "a synchronous first-delivery callback exception is consumed once and cannot replay create", ]: if marker not in create_order_tests: fail(f"API-store create response order regression missing: {marker}") @@ -454,7 +458,7 @@ for marker in [ deprecated = [path for path, item in packages.items() if item.get("deprecated")] print( "ui-ember-api-store-fetch-upgrade-ok " - f"version=2.8.5 api_store_compat_revision=5 ember-fetch=5.1.3 fetch_compat_revision=6 initializer_compat_revision=2 reference_compat_revision=2 " + f"version=2.8.5 api_store_compat_revision=6 ember-fetch=5.1.3 fetch_compat_revision=6 initializer_compat_revision=2 reference_compat_revision=2 " f"ember6_template_compat_revision=1 terminal_reconnect_revision=2 " f"deprecated_count={len(deprecated)} package_count={len(packages)}" ) diff --git a/scripts/node24-lock-smoke.js b/scripts/node24-lock-smoke.js index 9f85ad89c4..5b3f848e14 100644 --- a/scripts/node24-lock-smoke.js +++ b/scripts/node24-lock-smoke.js @@ -701,7 +701,7 @@ function expectEmberApiStoreFetchUpgrade() { if (JSON.stringify(apiStoreInfo.dependencies) !== JSON.stringify(expectedApiStoreDependencies)) { fail(`ember-api-store reviewed dependency boundary changed: ${JSON.stringify(apiStoreInfo.dependencies)}`); } - if (!apiStoreInfo.pasturestackCompatibility || apiStoreInfo.pasturestackCompatibility.revision !== 5) { + if (!apiStoreInfo.pasturestackCompatibility || apiStoreInfo.pasturestackCompatibility.revision !== 6) { fail("ember-api-store compatibility revision is missing"); } if (!emberFetchInfo.pasturestackCompatibility || emberFetchInfo.pasturestackCompatibility.revision !== 6) { @@ -725,6 +725,7 @@ function expectEmberApiStoreFetchUpgrade() { "addon/utils/fetch.js", "addon/utils/denormalize.js", "addon/utils/normalize.js", + "addon/utils/create-only-delivery.js", ]) { if (!fs.existsSync(path.join(apiStoreDir, filePath))) { fail(`ember-api-store required API file missing: ${filePath}`); @@ -738,15 +739,15 @@ function expectEmberApiStoreFetchUpgrade() { fail("ember-api-store deferred request initialization fix is missing"); } - expectVendoredFileSha256("vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.5.tgz", - "90da9ebdc36a8069629086d011e799691ace8f88c13d9c9df1333c77015a2ab8"); + expectVendoredFileSha256("vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.6.tgz", + "58079a9d1cc9539d89999f9fb3ac9f89464a1d79f45bb1c8ada18b8eda545bb6"); const typeMixin = fs.readFileSync(path.join(apiStoreDir, "addon/mixins/type.js"), "utf8"); const actionDispatch = typeMixin.split(" doAction: function(name, data, opt) {")[1].split(" save: function(opt) {")[0]; if (!actionDispatch.includes("delete opt.createIdentity;")) { fail("ember-api-store action POST must clear any reused create identity"); } - for (const marker of ["delete opt.createIdentity;", "if ( opt.method === 'POST' )", "opt.createIdentity = {", - "generation: get(store, 'generation')", "baseUrl: get(store, 'baseUrl')"]) { + for (const marker of ["delete opt.createIdentity;", "if ( opt.method === 'POST' )", "Object.defineProperty(opt, 'createIdentity'", + "generation: get(store, 'generation')", "baseUrl: get(store, 'baseUrl')", "fields[key].readOnCreateOnly === true"]) { if (!typeMixin.includes(marker)) { fail(`ember-api-store create-only save identity marker missing: ${marker}`); } @@ -831,7 +832,7 @@ function expectEmberApiStoreFetchUpgrade() { fail("ember-fetch native production wrapper smoke failed"); } - console.log("ember-api-store-fetch-upgrade-smoke-ok version=2.8.5 api_store_compat_revision=5 ember-fetch=5.1.3 fetch_compat_revision=6 native_fetch=ok legacy_build_graph=absent"); + console.log("ember-api-store-fetch-upgrade-smoke-ok version=2.8.5 api_store_compat_revision=6 ember-fetch=5.1.3 fetch_compat_revision=6 native_fetch=ok legacy_build_graph=absent"); } function expectBrowserGlobalBundle(file, globalName, expectedVersion) { diff --git a/tests/unit/mixins/new-or-edit-test.js b/tests/unit/mixins/new-or-edit-test.js index ed8b875c70..c37bd4936b 100644 --- a/tests/unit/mixins/new-or-edit-test.js +++ b/tests/unit/mixins/new-or-edit-test.js @@ -4,6 +4,7 @@ import { run } from '@ember/runloop'; import { defer, reject, resolve } from 'rsvp'; import { module, test } from 'qunit'; import NewOrEdit from 'ui/mixins/new-or-edit'; +import { takeCreateOnlyDelivery } from 'ember-api-store/utils/create-only-delivery'; module('Unit | Mixin | new or edit'); @@ -82,6 +83,79 @@ function save(subject, callback) { return subject.get('actions').save.call(subject, callback); } +test('opt-in delivery belongs to one save owner and is cleared on success, rejection and synchronous callback failures', async function(assert) { + for ( const mode of ['success', 'request-reject', 'doneSaving-throw', 'completion-throw'] ) { + const pending = defer(); + const failure = new Error(mode); + let options, callbackCalls = 0; + const subject = subjectWith({ + createOnlyDelivery: true, + doneSaving(value) { + assert.strictEqual(value, 'saved', 'existing hook argument is unchanged'); + if ( mode === 'doneSaving-throw' ) { throw failure; } + return value; + }, + }); + subject.get('model').save = value => { options = value; return pending.promise; }; + const operation = save(subject, () => { + callbackCalls++; + if ( mode === 'completion-throw' ) { throw failure; } + }); + for ( let turn = 0; !options && turn < 20; turn++ ) { await resolve(); } + assert.ok(options, 'base doSave binds private delivery options'); + const data = {fields: {secretValue: 'SECRET-TEST'}}; + takeCreateOnlyDelivery(options)(data); + assert.notOk(Object.keys(subject).includes('_createOnlyDelivery'), 'delivery metadata is nonenumerable'); + assert.notOk(Object.keys(subject).includes('_createOnlyRequest'), 'request metadata is nonenumerable'); + const owner = subject._saveOwner; + assert.deepEqual(await save(subject), {saved: false, reason: 'busy'}); + assert.strictEqual(subject._saveOwner, owner, 'duplicate cannot clear the owner'); + assert.strictEqual(subject._createOnlyDelivery.data, data, 'duplicate cannot consume the owner delivery'); + if ( mode === 'request-reject' ) { pending.reject(failure); } else { pending.resolve('saved'); } + try { + const result = await operation; + if ( mode === 'completion-throw' ) { assert.ok(false, 'callback failure must reject'); } + else if ( mode === 'success' ) { assert.strictEqual(result, 'saved', 'existing result is unchanged'); } + else { assert.strictEqual(result.error, failure); } + } catch (error) { + assert.strictEqual(error, failure); + assert.strictEqual(mode, 'completion-throw'); + } + assert.strictEqual(callbackCalls, 1); + assert.strictEqual(data.fields, null, `${mode} clears unconsumed one-time values`); + assert.strictEqual(takeCreateOnlyDelivery(options), null); + assert.strictEqual(subject._createOnlyRequest, null); + assert.strictEqual(subject._createOnlyDelivery, null); + assert.strictEqual(subject._saveOwner, null); + assert.strictEqual(subject.get('saving'), false); + run(() => subject.destroy()); + } +}); + +test('synchronous persistence failure clears private callback, while ordinary consumers keep their options and result', async function(assert) { + let options; + const subject = subjectWith({createOnlyDelivery: true}); + const failure = new Error('synchronous save failed'); + subject.get('model').save = value => { options = value; throw failure; }; + const outcome = await save(subject); + assert.strictEqual(outcome.error, failure); + assert.strictEqual(takeCreateOnlyDelivery(options), null); + assert.strictEqual(subject._createOnlyRequest, null); + assert.strictEqual(subject._saveOwner, null); + assert.strictEqual(subject.get('saving'), false); + run(() => subject.destroy()); + + const ordinary = subjectWith(); + ordinary.get('model').save = value => { + assert.strictEqual(value, undefined, 'non-opted-in consumers keep their previous arguments'); + return resolve('ordinary-saved'); + }; + assert.strictEqual(await save(ordinary), 'ordinary-saved'); + assert.strictEqual(ordinary._createOnlyRequest, undefined); + assert.strictEqual(ordinary._createOnlyDelivery, undefined); + run(() => ordinary.destroy()); +}); + test('validation cancellation returns an awaitable outcome and completes once', function(assert) { let callbacks = []; let saves = 0; diff --git a/tests/unit/vendor/api-store-create-order-test.js b/tests/unit/vendor/api-store-create-order-test.js index ec45c59669..c66b5e6a7b 100644 --- a/tests/unit/vendor/api-store-create-order-test.js +++ b/tests/unit/vendor/api-store-create-order-test.js @@ -6,14 +6,23 @@ import Store from 'ember-api-store/services/store'; import Resource from 'ember-api-store/models/resource'; import Schema from 'ember-api-store/models/schema'; import Collection from 'ember-api-store/models/collection'; +import { bindCreateOnlyDelivery, cloneCreateOnlyDelivery, takeCreateOnlyDelivery } from 'ember-api-store/utils/create-only-delivery'; +import EditApiKey from 'ui/components/edit-apikey/component'; +import EmberObject from '@ember/object'; +import Service from '@ember/service'; +import inertRenderer from '../../helpers/inert-renderer'; +import { createOwned, destroyOwned } from '../../helpers/owned-subject'; // Real installed compatibility package and Type.save. Only the HTTP boundary // is deferred: subscribe import must complete before the original 201 arrives. -function fixture(project = '1a-test') { +function fixture(project = '1a-test', baseUrl = `/v2-beta/projects/${project}`) { const objects = new Set(); const requests = []; - const store = Store.create({ baseUrl: `/v2-beta/projects/${project}` }); + const intl = Service.create({exists() { return false; }, t(key) { return key; }}); + objects.add(intl); + const store = Store.create({ baseUrl }); setOwner(store, { lookup(name) { + if ( name === 'service:intl' ) { return intl; } if ( name === 'service:fastboot' ) { return { isFastBoot: false }; } const Factory = name === 'model:schema' ? Schema : name === 'model:collection' ? Collection : Resource; @@ -24,16 +33,31 @@ function fixture(project = '1a-test') { const createRecord = store.createRecord.bind(store); store.createRecord = (...args) => { const object = createRecord(...args); + // The real Resource validator reads model.intl, not component.intl. + // Service injection is not an enumerable resource/API payload field. + Object.defineProperty(object, 'intl', {value: intl, configurable: true}); objects.add(object); return object; }; const response = defer(); - store.rawRequest = (options) => { requests.push(options); return response.promise; }; + const requestEntered = defer(); + store.rawRequest = (options) => { + requests.push(options); + requestEntered.resolve(options); + return response.promise; + }; store._bulkAdd('schema', ['volume', 'loadBalancerService', 'service'].map(id => ({ type: 'schema', id, resourceFields: {}, collectionMethods: ['GET', 'POST'], links: { collection: `${store.baseUrl}/${id}s` }, }))); - return { store, requests, response, + store._bulkAdd('schema', [{type: 'schema', id: 'apiKey', + collectionMethods: ['GET', 'POST'], resourceFields: { + name: {type: 'string', create: true}, + publicValue: {type: 'string', create: false}, + secretValue: {type: 'password', create: false, update: false, readOnCreateOnly: true}, + nested: {type: 'service'}, + }, links: {collection: `${store.baseUrl}/apikeys`}}]); + return { store, requests, response, requestEntered, intl, destroy() { store.all('schema').forEach(object => objects.add(object)); run(() => { @@ -52,6 +76,162 @@ const current = (type = 'volume', id = 'Opaque-ID') => ({ }); module('Unit | Vendor | API store create response order', function() { + test('apiKey first delivery survives redacted subscribe before 201 and later redaction in personal and project stores, 100 deterministic barriers each', async function(assert) { + for ( const [accountId, baseUrl] of [['1a-owner', '/v2-beta'], ['1a-project', '/v2-beta/projects/1a-project']] ) { + for ( let index = 0; index < 100; index++ ) { + const f = fixture(accountId, baseUrl); + let subject; + try { + const original = f.store.createRecord({type: 'apiKey', name: 'created', accountId}); + const draft = original.clone(); + subject = createOwned(EditApiKey, { + renderer: inertRenderer(), + intl: f.intl, + modalService: EmberObject.create({modalOpts: original}), + model: draft, + clone: original.clone(), + didSave(resource) { + assert.strictEqual(imports, 0, '201 adoption performs no stale mangleIn or nested import'); + return resource; + }, + }, 'component'); + const savingModel = subject.get('primaryResource'); + assert.strictEqual(savingModel, subject.get('model'), 'actual editor saves its model'); + assert.strictEqual(savingModel.get('intl'), f.intl, 'actual Resource receives the shared intl service'); + assert.deepEqual(savingModel.validationErrors(), [], 'real model validation accepts the fixture without bypassing willSave'); + assert.notOk(Object.hasOwn(savingModel.serialize(), 'intl'), 'service is not serialized into create payload'); + assert.strictEqual(savingModel.get('accountId'), accountId); + assert.notOk(savingModel.get('id'), 'actual editor starts with an ID-less draft'); + const completion = []; + const saving = run(() => subject.get('actions').save.call(subject, success => completion.push(success))); + // Wait for real transport entry. A cancelled/failed lifecycle wins + // the race instead of hanging or assuming N microtask turns suffice. + const boundary = await Promise.race([ + f.requestEntered.promise.then(options => ({options})), + saving.then(outcome => ({earlyOutcome: outcome}), error => ({earlyError: error})), + ]); + const earlyError = boundary.earlyError || (boundary.earlyOutcome && boundary.earlyOutcome.error); + const diagnosis = {reason: boundary.earlyOutcome && boundary.earlyOutcome.reason, + saved: boundary.earlyOutcome && boundary.earlyOutcome.saved, + error: earlyError && earlyError.message, errors: subject.get('errors')}; + assert.ok(boundary.options, `actual request entered, otherwise early lifecycle: ${JSON.stringify(diagnosis)}`); + assert.strictEqual(f.requests.length, 1, 'the actual save reaches its deferred HTTP boundary'); + if ( !boundary.options ) { return; } + const nested = run(() => f.store._typeify({...current('service', 'Nested-ID'), state: 'active'})); + run(() => f.store._typeify({type: 'apiKey', id: 'Key-ID', accountId, + name: 'created', state: 'active', publicValue: 'PUBLIC-TEST', secretValue: null, nested})); + let imports = 0; + const createRecord = f.store.createRecord; + f.store.createRecord = (...args) => { imports++; return createRecord(...args); }; + const body = {type: 'apiKey', id: 'Key-ID', accountId, name: 'created', + state: 'registering', publicValue: 'PUBLIC-TEST', secretValue: 'SECRET-TEST', + nested: {...initial('service', 'Nested-ID'), state: 'creating'}}; + const xhr = {status: 201, body}; + run(() => f.response.resolve(xhr)); + await saving; + const canonical = f.store.getById('apiKey', 'Key-ID'); + const clone = subject.get('clone'); + assert.strictEqual(canonical, savingModel, 'actual editor draft canonical save identity is retained'); + assert.strictEqual(canonical.get('state'), 'active', 'newer cached state wins'); + assert.strictEqual(nested.get('state'), 'active', 'stale nested 201 is not imported'); + assert.strictEqual(clone.get('secretValue'), 'SECRET-TEST', 'actual API-key doneSaving receives one-time secret'); + assert.strictEqual(clone.get('publicValue'), 'PUBLIC-TEST'); + assert.strictEqual(canonical.get('secretValue'), null, 'canonical store never needs to retain secret'); + assert.notOk(JSON.stringify(canonical.serialize()).includes('SECRET-TEST')); + assert.notOk(Object.hasOwn(f.requests[0].data, 'createIdentity')); + assert.notOk(Object.keys(f.requests[0]).includes('createIdentity'), 'create marker is nonenumerable'); + assert.notOk(JSON.stringify(f.requests[0]).includes('SECRET-TEST'), 'request metadata contains no secret'); + assert.deepEqual(completion, [true]); + assert.strictEqual(subject._createOnlyDelivery, null); + assert.strictEqual(subject._createOnlyRequest, null); + assert.strictEqual(subject._saveOwner, null); + assert.strictEqual(subject.get('saving'), false); + assert.notOk(xhr.body, 'raw 201 body is not retained'); + run(() => f.store._typeify({type: 'apiKey', id: 'Key-ID', accountId, + name: 'created', state: 'active', publicValue: 'PUBLIC-TEST', secretValue: null})); + assert.strictEqual(canonical.get('secretValue'), null); + assert.strictEqual(clone.get('secretValue'), 'SECRET-TEST', 'later WS cannot erase detached visible delivery'); + assert.strictEqual(f.requests.length, 1, 'no replay or extra request'); + } finally { + if ( subject ) { destroyOwned(subject); } + f.destroy(); + } + } + } + }); + + test('uncached API-key create-only delivery is one-shot and schema-bound, with private request metadata', async function(assert) { + const f = fixture(); + try { + let data, calls = 0; + const options = {}; + bindCreateOnlyDelivery(options, value => { data = value; calls++; }); + const draft = f.store.createRecord({type: 'apiKey', name: 'created'}); + const saving = run(() => draft.save(options)); + run(() => f.response.resolve({status: 201, body: {type: 'apiKey', id: 'Key-ID', + state: 'requested', name: 'created', secretValue: 'SECRET-TEST', publicValue: 'PUBLIC-TEST'}})); + assert.strictEqual(await saving, draft); + assert.strictEqual(calls, 1); + assert.deepEqual(data.fields, {secretValue: 'SECRET-TEST'}, 'only exact readOnCreateOnly=true fields are delivered'); + assert.strictEqual(draft.get('secretValue'), null); + const clone = cloneCreateOnlyDelivery(draft, data); + assert.strictEqual(clone.get('secretValue'), 'SECRET-TEST'); + assert.strictEqual(data.fields, null, 'delivery is consumed'); + assert.throws(() => cloneCreateOnlyDelivery(draft, data), /no longer belongs/); + assert.strictEqual(takeCreateOnlyDelivery(options), null); + } finally { f.destroy(); } + }); + + test('create-only delivery rejects changed store, generation, base, concrete type and owner without importing its secret', async function(assert) { + for ( const change of ['generation', 'base', 'type', 'owner'] ) { + const f = fixture(); + try { + let calls = 0; + const options = {}; + bindCreateOnlyDelivery(options, () => calls++); + const draft = f.store.createRecord({type: 'apiKey', accountId: '1a-test'}); + const saving = run(() => draft.save(options)); + if ( change === 'generation' ) { run(() => f.store.reset()); } + if ( change === 'base' ) { f.store.set('baseUrl', '/v2-beta/projects/other'); } + if ( change === 'owner' ) { run(() => f.store._typeify({type: 'apiKey', id: 'Key-ID', accountId: '1a-other', state: 'active', secretValue: null})); } + run(() => f.response.resolve({status: 201, body: {type: change === 'type' ? 'volume' : 'apiKey', id: 'Key-ID', accountId: '1a-test', secretValue: 'SECRET-TEST'}})); + await saving; + assert.strictEqual(calls, 0, `${change} cannot receive first delivery`); + assert.notStrictEqual(draft.get('secretValue'), 'SECRET-TEST'); + } finally { f.destroy(); } + } + const f = fixture(), other = fixture(); + try { + const resource = other.store._typeify({type: 'apiKey', id: 'Key-ID'}); + assert.throws(() => cloneCreateOnlyDelivery(resource, {id: 'Key-ID', type: 'apikey', + store: f.store, generation: f.store.generation, baseUrl: f.store.baseUrl, fields: {secretValue: 'SECRET-TEST'}}), /no longer belongs/); + } finally { f.destroy(); other.destroy(); } + }); + + test('a synchronous first-delivery callback exception is consumed once and cannot replay create', async function(assert) { + const f = fixture(); + try { + let calls = 0, delivered; + const options = {}; + const failure = new Error('delivery callback failed'); + bindCreateOnlyDelivery(options, value => { delivered = value; calls++; throw failure; }); + const draft = f.store.createRecord({type: 'apiKey'}); + const saving = run(() => draft.save(options)); + // Attach before fulfilling HTTP: RSVP must not report an unhandled + // rejection while the native async test has not resumed yet. + const handled = saving.then(value => ({value}), error => ({error})); + run(() => f.response.resolve({status: 201, body: {type: 'apiKey', id: 'Key-ID', secretValue: 'SECRET-TEST'}})); + const outcome = await handled; + assert.ok(outcome.error, 'callback failure rejects create completion'); + assert.strictEqual(outcome.error.get('message'), failure.message, 'existing API error message is retained'); + assert.strictEqual(outcome.error.xhr, failure, 'existing API error wrapper retains the exact original exception'); + assert.strictEqual(calls, 1); + assert.strictEqual(delivered.fields, null, 'synchronous callback failure clears one-time values'); + assert.strictEqual(takeCreateOnlyDelivery(options), null); + assert.strictEqual(f.requests.length, 1); + assert.strictEqual(f.store.getById('apiKey', 'Key-ID').get('secretValue'), null); + } finally { f.destroy(); } + }); test('delayed 201 cannot overwrite the newer subscribe model, repeated with deterministic barriers 100 times', async function(assert) { for ( let index = 0; index < 100; index++ ) { const f = fixture(); diff --git a/vendor/ember-api-store-compat/UPSTREAM.md b/vendor/ember-api-store-compat/UPSTREAM.md index 35113d99fb..d13e346145 100644 --- a/vendor/ember-api-store-compat/UPSTREAM.md +++ b/vendor/ember-api-store-compat/UPSTREAM.md @@ -38,3 +38,13 @@ body. Save completion, base-type aliases, HTTP metadata and errors retain their existing contracts. GET, PUT, actions, non-201 responses and uncached creates continue through the original import path. This does not order resource states or event timestamps, grant permissions, change API responses, or add requests. + +Compatibility revision 6 adds opt-in first delivery of Schema fields explicitly +marked `readOnCreateOnly`, exported by the Engine auth overlay's `o` permission. +The create request captures only their field names. A request-private, +nonenumerable callback transports the successful 201 values once to a detached +consumer clone, outside the canonical store and serialized request. This keeps +revision 5's newer subscribe state and nested-resource adoption intact, validates +the same store, generation, API base, generated ID, concrete type and owner, and +does not require a canonical resource to retain secrets after create. Existing +save hooks, non-opted-in consumers, errors, and request counts are unchanged. diff --git a/vendor/ember-api-store-compat/addon/mixins/type.js b/vendor/ember-api-store-compat/addon/mixins/type.js index e240df0752..c0ff4aa15a 100644 --- a/vendor/ember-api-store-compat/addon/mixins/type.js +++ b/vendor/ember-api-store-compat/addon/mixins/type.js @@ -164,11 +164,13 @@ var Type = Mixin.create(Serializable,{ // A generated ID may arrive over subscribe before its original 201. // Bind this create-only adoption to the store that started the request. if ( opt.method === 'POST' ) { - opt.createIdentity = { - type, - generation: get(store, 'generation'), - baseUrl: get(store, 'baseUrl'), - }; + const schema = store.getById('schema', type); + const fields = schema && get(schema, 'store') === store ? get(schema, 'resourceFields') || {} : {}; + Object.defineProperty(opt, 'createIdentity', { + configurable: true, + value: { type, generation: get(store, 'generation'), baseUrl: get(store, 'baseUrl'), + readOnCreateFields: Object.keys(fields).filter(key => fields[key].readOnCreateOnly === true) }, + }); } } diff --git a/vendor/ember-api-store-compat/addon/services/store.js b/vendor/ember-api-store-compat/addon/services/store.js index 8f3700eecd..024524ad65 100644 --- a/vendor/ember-api-store-compat/addon/services/store.js +++ b/vendor/ember-api-store-compat/addon/services/store.js @@ -11,6 +11,7 @@ import { reject, resolve, defer } from 'rsvp'; import Service, { service } from '@ember/service'; import { isArray } from '@ember/array'; import { parse as setCookieParser } from 'set-cookie-parser'; +import { takeCreateOnlyDelivery } from '../utils/create-only-delivery'; function getOwnerKey() { const x = {}; @@ -510,6 +511,7 @@ var Store = Service.extend({ _requestSuccess(xhr,opt) { opt.responseStatus = xhr.status; + const firstDelivery = takeCreateOnlyDelivery(opt); if ( xhr.status === 204 ) { return; @@ -517,7 +519,24 @@ var Store = Service.extend({ if ( xhr.body && typeof xhr.body === 'object' ) { let response; + let delivery; const creation = opt.createIdentity; + const createOnlyFields = {}; + // Keep one-time fields out of the canonical import even if the original + // store generation changed before the response arrived. A stale marker + // must neither deliver its values nor retain them in a different cache. + if ( firstDelivery && creation && xhr.status === 201 && opt.method === 'POST' ) { + // Field names were captured from this request's actual Schema, so a + // later reset cannot turn a one-time value into a canonical field. + (creation.readOnCreateFields || []).forEach((key) => { + if ( Object.hasOwn(xhr.body, key) ) { + if ( xhr.body[key] !== null && xhr.body[key] !== undefined ) { + createOnlyFields[key] = JSON.parse(JSON.stringify(xhr.body[key])); + } + xhr.body[key] = null; + } + }); + } // Only a new-record save can use this rule. Its 201 is the initial // snapshot; the same generated ID already in this store has arrived // through subscribe while that response was in flight. Do not import @@ -533,10 +552,26 @@ var Store = Service.extend({ get(cached, 'store') === this && this.hasRecord(cached) ) { response = cached; } + if ( Object.keys(createOnlyFields).length && + (!response || get(response, 'accountId') === xhr.body.accountId) ) { + // Engine's auth overlay "o" exports readOnCreateOnly. Transport + // only those schema-bound values; no stale state or nested imports. + delivery = { id: xhr.body.id, type: creation.type, store: this, + accountId: xhr.body.accountId, generation: creation.generation, + baseUrl: creation.baseUrl, fields: createOnlyFields }; + } } response = response || this._typeify(xhr.body); delete xhr.body; Object.defineProperty(response, 'xhr', {value: xhr, configurable: true}); + if ( delivery ) { + try { + firstDelivery(delivery); + } catch (error) { + delivery.fields = null; + throw error; + } + } // Depaginate if ( opt.depaginate && typeof response.depaginate === 'function' ) { @@ -554,6 +589,7 @@ var Store = Service.extend({ }, _requestFailed(xhr,opt) { + takeCreateOnlyDelivery(opt); var body; if ( xhr.err ) { diff --git a/vendor/ember-api-store-compat/addon/utils/create-only-delivery.js b/vendor/ember-api-store-compat/addon/utils/create-only-delivery.js new file mode 100644 index 0000000000..c13aa7a397 --- /dev/null +++ b/vendor/ember-api-store-compat/addon/utils/create-only-delivery.js @@ -0,0 +1,36 @@ +import { get } from '@ember/object'; +import { normalizeType } from './normalize'; + +// Request-local metadata: not a resource field, payload key, or store cache. +const callbackKey = Symbol('create-only first delivery'); + +export function bindCreateOnlyDelivery(options, callback) { + Object.defineProperty(options, callbackKey, { value: callback, configurable: true }); +} + +export function takeCreateOnlyDelivery(options) { + const callback = options[callbackKey]; + delete options[callbackKey]; + return typeof callback === 'function' ? callback : null; +} + +export function cloneCreateOnlyDelivery(resource, delivery) { + if ( !delivery ) { + return resource.clone(); + } + + const fields = delivery.fields; + delivery.fields = null; // Consume even when identity validation or cloning fails. + const store = get(resource, 'store'); + if ( !fields || store !== delivery.store || get(resource, 'id') !== delivery.id || + normalizeType(get(resource, 'type')) !== delivery.type || + get(resource, 'accountId') !== delivery.accountId || + get(store, 'generation') !== delivery.generation || + get(store, 'baseUrl') !== delivery.baseUrl ) { + throw new Error('Create-only delivery no longer belongs to this save'); + } + + const clone = resource.clone(); + clone.setProperties(fields); + return clone; +} diff --git a/vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.6.tgz b/vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.6.tgz new file mode 100644 index 0000000000..b4c9c262ee Binary files /dev/null and b/vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.6.tgz differ diff --git a/vendor/ember-api-store-compat/package.json b/vendor/ember-api-store-compat/package.json index b31613d17f..932f31ac47 100644 --- a/vendor/ember-api-store-compat/package.json +++ b/vendor/ember-api-store-compat/package.json @@ -32,7 +32,7 @@ "node": ">=24" }, "pasturestackCompatibility": { - "revision": 5, + "revision": 6, "upstreamPackage": "ember-api-store", "upstreamVersion": "2.8.5", "upstreamIntegrity": "sha512-YvnBZfdNGG7hB25hecEENHObXNN+186Bbkd1wAIL7qtRXqboQsQxTU05xxP7axgW6TPYfUYMxZ+GgbHgD14g2A=="