diff --git a/packages/request/src/-private/utils.ts b/packages/request/src/-private/utils.ts index c6df020c5ee..1d5d773e9f0 100644 --- a/packages/request/src/-private/utils.ts +++ b/packages/request/src/-private/utils.ts @@ -1,11 +1,12 @@ import { DEBUG } from '@warp-drive/build-config/env'; import { getOrSetGlobal } from '@warp-drive/core-types/-private'; -import { - type RequestInfo, - STRUCTURED, - type StructuredDataDocument, - type StructuredErrorDocument, +import type { + RequestInfo, + StructuredDataDocument, + StructuredDocument, + StructuredErrorDocument, } from '@warp-drive/core-types/request'; +import { STRUCTURED } from '@warp-drive/core-types/request'; import { Context, ContextOwner } from './context'; import { assertValidRequest } from './debug'; @@ -59,6 +60,28 @@ function isDoc(doc: T | StructuredDataDocument): doc is StructuredDataDocu return doc && (doc as StructuredDataDocument)[STRUCTURED] === true; } +function ensureDoc(owner: ContextOwner, content: T | Error, isError: boolean): StructuredDocument { + if (isDoc(content)) { + return content as StructuredDocument; + } + + if (isError) { + return { + [STRUCTURED]: true, + request: owner.request, + response: owner.getResponse(), + error: content as Error, + } as StructuredErrorDocument; + } + + return { + [STRUCTURED]: true, + request: owner.request, + response: owner.getResponse(), + content: content as T, + }; +} + export type HttpErrorProps = { code: number; name: string; @@ -151,7 +174,7 @@ export function executeNextHandler( outcome = wares[i].request(context, next); if (!!outcome && isCacheHandler(wares[i], i)) { if (!(outcome instanceof Promise)) { - setRequestResult(owner.requestId, { isError: false, result: outcome }); + setRequestResult(owner.requestId, { isError: false, result: ensureDoc(owner, outcome, false) }); outcome = Promise.resolve(outcome); } } else if (DEBUG) { @@ -166,7 +189,7 @@ export function executeNextHandler( } } catch (e) { if (isCacheHandler(wares[i], i)) { - setRequestResult(owner.requestId, { isError: true, result: e }); + setRequestResult(owner.requestId, { isError: true, result: ensureDoc(owner, e, true) }); } outcome = Promise.reject>(e); } diff --git a/packages/schema-record/eslint.config.mjs b/packages/schema-record/eslint.config.mjs index 63296bac802..7a36b3e30d3 100644 --- a/packages/schema-record/eslint.config.mjs +++ b/packages/schema-record/eslint.config.mjs @@ -11,7 +11,7 @@ export default [ // browser (js/ts) ================ typescript.browser({ srcDirs: ['src'], - allowedImports: ['@ember/debug'], + allowedImports: ['@ember/debug', '@ember/-internals/metal'], }), // node (module) ================ diff --git a/packages/schema-record/src/record.ts b/packages/schema-record/src/record.ts index 679da42f908..3451d04766f 100644 --- a/packages/schema-record/src/record.ts +++ b/packages/schema-record/src/record.ts @@ -463,6 +463,10 @@ export class SchemaRecord { if (prop === 'constructor') { return SchemaRecord; } + // too many things check for random symbols + if (typeof prop === 'symbol') { + return undefined; + } throw new Error(`No field named ${String(prop)} on ${identifier.type}`); } diff --git a/packages/store/src/-private/cache-handler.ts b/packages/store/src/-private/cache-handler.ts index b116e236b9f..e4da4771b4c 100644 --- a/packages/store/src/-private/cache-handler.ts +++ b/packages/store/src/-private/cache-handler.ts @@ -507,9 +507,12 @@ export const CacheHandler: CacheHandlerType = { store.requestManager._pending.set(context.id, promise); } + assert(`Expected a peeked request to be present`, peeked); + const shouldHydrate: boolean = context.request[EnableHydration] || false; + context.setResponse(peeked.response); - if ('error' in peeked!) { + if ('error' in peeked) { const content = shouldHydrate ? maybeUpdateUiObjects( store, @@ -529,10 +532,10 @@ export const CacheHandler: CacheHandlerType = { store, context.request, { shouldHydrate, identifier }, - peeked!.content as ResourceDataDocument, + peeked.content as ResourceDataDocument, true ) - : (peeked!.content as T); + : (peeked.content as T); return result; }, diff --git a/tests/warp-drive__ember/.mock-cache/733572b0/GET-0-users/1/res.body.br b/tests/warp-drive__ember/.mock-cache/733572b0/GET-0-users/1/res.body.br deleted file mode 100644 index 95a61de6c7d..00000000000 Binary files a/tests/warp-drive__ember/.mock-cache/733572b0/GET-0-users/1/res.body.br and /dev/null differ diff --git a/tests/warp-drive__ember/.mock-cache/99723d91/GET-0-users/2/res.body.br b/tests/warp-drive__ember/.mock-cache/99723d91/GET-0-users/2/res.body.br new file mode 100644 index 00000000000..7524b09c463 Binary files /dev/null and b/tests/warp-drive__ember/.mock-cache/99723d91/GET-0-users/2/res.body.br differ diff --git a/tests/warp-drive__ember/.mock-cache/733572b0/GET-0-users/1/res.meta.json b/tests/warp-drive__ember/.mock-cache/99723d91/GET-0-users/2/res.meta.json similarity index 91% rename from tests/warp-drive__ember/.mock-cache/733572b0/GET-0-users/1/res.meta.json rename to tests/warp-drive__ember/.mock-cache/99723d91/GET-0-users/2/res.meta.json index 82a5960c1e6..61a2d9f6259 100644 --- a/tests/warp-drive__ember/.mock-cache/733572b0/GET-0-users/1/res.meta.json +++ b/tests/warp-drive__ember/.mock-cache/99723d91/GET-0-users/2/res.meta.json @@ -1,5 +1,5 @@ { - "url": "users/1", + "url": "users/2", "status": 200, "statusText": "OK", "headers": { diff --git a/tests/warp-drive__ember/.mock-cache/a12647a9/GET-0-users/2/res.body.br b/tests/warp-drive__ember/.mock-cache/a12647a9/GET-0-users/2/res.body.br new file mode 100644 index 00000000000..7524b09c463 Binary files /dev/null and b/tests/warp-drive__ember/.mock-cache/a12647a9/GET-0-users/2/res.body.br differ diff --git a/tests/warp-drive__ember/.mock-cache/eea0d109/GET-0-users/1/res.meta.json b/tests/warp-drive__ember/.mock-cache/a12647a9/GET-0-users/2/res.meta.json similarity index 91% rename from tests/warp-drive__ember/.mock-cache/eea0d109/GET-0-users/1/res.meta.json rename to tests/warp-drive__ember/.mock-cache/a12647a9/GET-0-users/2/res.meta.json index 82a5960c1e6..61a2d9f6259 100644 --- a/tests/warp-drive__ember/.mock-cache/eea0d109/GET-0-users/1/res.meta.json +++ b/tests/warp-drive__ember/.mock-cache/a12647a9/GET-0-users/2/res.meta.json @@ -1,5 +1,5 @@ { - "url": "users/1", + "url": "users/2", "status": 200, "statusText": "OK", "headers": { diff --git a/tests/warp-drive__ember/.mock-cache/eea0d109/GET-0-users/1/res.body.br b/tests/warp-drive__ember/.mock-cache/eea0d109/GET-0-users/1/res.body.br deleted file mode 100644 index 95a61de6c7d..00000000000 Binary files a/tests/warp-drive__ember/.mock-cache/eea0d109/GET-0-users/1/res.body.br and /dev/null differ diff --git a/tests/warp-drive__ember/app/services/store.ts b/tests/warp-drive__ember/app/services/store.ts index 07c322a36fa..60df2cdf5fa 100644 --- a/tests/warp-drive__ember/app/services/store.ts +++ b/tests/warp-drive__ember/app/services/store.ts @@ -7,6 +7,7 @@ import type { StableRecordIdentifier } from '@warp-drive/core-types'; import type { Cache } from '@warp-drive/core-types/cache'; import { instantiateRecord, teardownRecord } from '@warp-drive/schema-record/hooks'; import type { SchemaRecord } from '@warp-drive/schema-record/record'; +import { SchemaService } from '@warp-drive/schema-record/schema'; export default class Store extends DataStore { constructor(args: unknown) { @@ -17,15 +18,19 @@ export default class Store extends DataStore { manager.useCache(CacheHandler); } - override createCache(capabilities: CacheCapabilitiesManager): Cache { + createSchemaService() { + return new SchemaService(); + } + + createCache(capabilities: CacheCapabilitiesManager): Cache { return new JSONAPICache(capabilities); } - override instantiateRecord(identifier: StableRecordIdentifier, createArgs?: Record): SchemaRecord { + instantiateRecord(identifier: StableRecordIdentifier, createArgs?: Record): SchemaRecord { return instantiateRecord(this, identifier, createArgs); } - override teardownRecord(record: SchemaRecord): void { + teardownRecord(record: SchemaRecord): void { return teardownRecord(record); } } diff --git a/tests/warp-drive__ember/tests/integration/get-request-state-test.gts b/tests/warp-drive__ember/tests/integration/get-request-state-test.gts index 17067aa0471..080e4c9242f 100644 --- a/tests/warp-drive__ember/tests/integration/get-request-state-test.gts +++ b/tests/warp-drive__ember/tests/integration/get-request-state-test.gts @@ -3,6 +3,7 @@ import { rerender, settled } from '@ember/test-helpers'; import type { CacheHandler, Future, NextFn, RequestContext, StructuredDataDocument } from '@ember-data/request'; import RequestManager from '@ember-data/request'; import Fetch from '@ember-data/request/fetch'; +import { buildBaseURL } from '@ember-data/request-utils'; import type { RenderingTestContext } from '@warp-drive/diagnostic/ember'; import { module, setupRenderingTest, test as _test } from '@warp-drive/diagnostic/ember'; import { getRequestState } from '@warp-drive/ember'; @@ -77,7 +78,7 @@ async function mockGETSuccess(context: LocalTestContext): Promise { }), { RECORD: RECORD } ); - return 'https://localhost:1135/users/1'; + return buildBaseURL({ resourcePath: 'users/1' }); } async function mockGETFailure(context: LocalTestContext): Promise { await mock( @@ -102,7 +103,7 @@ async function mockGETFailure(context: LocalTestContext): Promise { RECORD ); - return 'https://localhost:1135/users/2'; + return buildBaseURL({ resourcePath: 'users/2' }); } module('Integration | get-request-state', function (hooks) { @@ -169,9 +170,11 @@ module('Integration | get-request-state', function (hooks) { status: 404, name: 'NotFoundError', isRequestError: true, - error: '[404 Not Found] GET (cors) - https://localhost:1135/users/2?__xTestId=b830e11d&__xTestRequestNumber=0', + error: `[404 Not Found] GET (cors) - ${buildBaseURL({ + resourcePath: 'users/2', + })}?__xTestId=b830e11d&__xTestRequestNumber=0`, statusText: 'Not Found', - message: '[404 Not Found] GET (cors) - https://localhost:1135/users/2', + message: `[404 Not Found] GET (cors) - ${buildBaseURL({ resourcePath: 'users/2' })}`, errors: [{ status: '404', title: 'Not Found', detail: 'The resource does not exist.' }], content: { errors: [{ status: '404', title: 'Not Found', detail: 'The resource does not exist.' }], @@ -414,13 +417,13 @@ module('Integration | get-request-state', function (hooks) { assert.true(state1!.error instanceof Error, 'error is an instance of Error'); assert.equal( (state1!.error as Error | undefined)?.message, - '[404 Not Found] GET (cors) - https://localhost:1135/users/2', + `[404 Not Found] GET (cors) - ${buildBaseURL({ resourcePath: 'users/2' })}`, 'error message is correct' ); assert.equal(counter, 2, 'counter is 2'); assert.equal( this.element.textContent?.trim(), - '[404 Not Found] GET (cors) - https://localhost:1135/users/2\n Count:\n 2' + `[404 Not Found] GET (cors) - ${buildBaseURL({ resourcePath: 'users/2' })}\n Count:\n 2` ); }); @@ -463,26 +466,26 @@ module('Integration | get-request-state', function (hooks) { assert.true(state1!.error instanceof Error, 'error is an instance of Error'); assert.equal( (state1!.error as Error | undefined)?.message, - '[404 Not Found] GET (cors) - https://localhost:1135/users/2', + `[404 Not Found] GET (cors) - ${buildBaseURL({ resourcePath: 'users/2' })}`, 'error message is correct' ); assert.equal(counter, 1, 'counter is 1'); assert.equal( this.element.textContent?.trim(), - '[404 Not Found] GET (cors) - https://localhost:1135/users/2\n Count:\n 1' + `[404 Not Found] GET (cors) - ${buildBaseURL({ resourcePath: 'users/2' })}\n Count:\n 1` ); await rerender(); assert.equal(state1!.result, null, 'after rerender result is still null'); assert.true(state1!.error instanceof Error, 'error is an instance of Error'); assert.equal( (state1!.error as Error | undefined)?.message, - '[404 Not Found] GET (cors) - https://localhost:1135/users/2', + `[404 Not Found] GET (cors) - ${buildBaseURL({ resourcePath: 'users/2' })}`, 'error message is correct' ); assert.equal(counter, 1, 'counter is 1'); assert.equal( this.element.textContent?.trim(), - '[404 Not Found] GET (cors) - https://localhost:1135/users/2\n Count:\n 1' + `[404 Not Found] GET (cors) - ${buildBaseURL({ resourcePath: 'users/2' })}\n Count:\n 1` ); }); }); diff --git a/tests/warp-drive__ember/tests/integration/request-component-test.gts b/tests/warp-drive__ember/tests/integration/request-component-test.gts index 7be5d238751..647317f56e7 100644 --- a/tests/warp-drive__ember/tests/integration/request-component-test.gts +++ b/tests/warp-drive__ember/tests/integration/request-component-test.gts @@ -2,16 +2,21 @@ import { fn } from '@ember/helper'; import { on } from '@ember/modifier'; import { click, rerender, settled } from '@ember/test-helpers'; +import { tracked } from '@glimmer/tracking'; import type { CacheHandler, Future, NextFn, RequestContext, StructuredDataDocument } from '@ember-data/request'; import RequestManager from '@ember-data/request'; import Fetch from '@ember-data/request/fetch'; +import { buildBaseURL } from '@ember-data/request-utils'; import type Store from '@ember-data/store'; +import { CacheHandler as StoreHandler } from '@ember-data/store'; +import type { SingleResourceDataDocument } from '@warp-drive/core-types/spec/document'; import type { RenderingTestContext } from '@warp-drive/diagnostic/ember'; import { module, setupRenderingTest, test as _test } from '@warp-drive/diagnostic/ember'; import { getRequestState, Request } from '@warp-drive/ember'; import { mock, MockServerHandler } from '@warp-drive/holodeck'; import { GET } from '@warp-drive/holodeck/mock'; +import { registerDerivations, withDefaults } from '@warp-drive/schema-record/schema'; // our tests use a rendering test context and add manager to it interface LocalTestContext extends RenderingTestContext { @@ -95,6 +100,7 @@ class SimpleCacheHandler implements CacheHandler { } async function mockGETSuccess(context: LocalTestContext, attributes?: { name: string }): Promise { + const url = buildBaseURL({ resourcePath: 'users/1' }); await GET( context, 'users/1', @@ -112,9 +118,10 @@ async function mockGETSuccess(context: LocalTestContext, attributes?: { name: st }), { RECORD: RECORD } ); - return 'https://localhost:1135/users/1'; + return url; } async function mockGETFailure(context: LocalTestContext): Promise { + const url = buildBaseURL({ resourcePath: 'users/2' }); await mock( context, () => ({ @@ -137,9 +144,10 @@ async function mockGETFailure(context: LocalTestContext): Promise { RECORD ); - return 'https://localhost:1135/users/2'; + return url; } async function mockRetrySuccess(context: LocalTestContext): Promise { + const url = buildBaseURL({ resourcePath: 'users/2' }); await GET( context, 'users/2', @@ -154,7 +162,7 @@ async function mockRetrySuccess(context: LocalTestContext): Promise { }), { RECORD: RECORD } ); - return 'https://localhost:1135/users/2'; + return url; } module('Integration | ', function (hooks) { @@ -291,14 +299,11 @@ module('Integration | ', function (hooks) { assert.true(state.error instanceof Error, 'error is an instance of Error'); assert.equal( (state.error as Error | undefined)?.message, - '[404 Not Found] GET (cors) - https://localhost:1135/users/2', + `[404 Not Found] GET (cors) - ${url}`, 'error message is correct' ); assert.equal(counter, 2, 'counter is 2'); - assert.equal( - this.element.textContent?.trim(), - '[404 Not Found] GET (cors) - https://localhost:1135/users/2Count: 2' - ); + assert.equal(this.element.textContent?.trim(), `[404 Not Found] GET (cors) - ${url}Count: 2`); }); test('we can retry from error state', async function (assert) { @@ -347,14 +352,11 @@ module('Integration | ', function (hooks) { assert.true(state2.error instanceof Error, 'error is an instance of Error'); assert.equal( (state2.error as Error | undefined)?.message, - '[404 Not Found] GET (cors) - https://localhost:1135/users/2', + `[404 Not Found] GET (cors) - ${url}`, 'error message is correct' ); assert.equal(counter, 2, 'counter is 2'); - assert.equal( - this.element.textContent?.trim(), - '[404 Not Found] GET (cors) - https://localhost:1135/users/2Count:2Retry' - ); + assert.equal(this.element.textContent?.trim(), `[404 Not Found] GET (cors) - ${url}Count:2Retry`); await click('[test-id="retry-button"]'); @@ -363,6 +365,138 @@ module('Integration | ', function (hooks) { assert.equal(this.element.textContent?.trim(), 'Chris ThoburnCount: 4'); }); + test('externally retriggered request works as expected', async function (assert) { + const url = await mockRetrySuccess(this); + const request = this.manager.request({ url, method: 'GET' }); + const state2 = getRequestState(request); + + class RequestSource { + @tracked request: Future = request; + } + const source = new RequestSource(); + + let counter = 0; + function countFor(_result: unknown) { + return ++counter; + } + function retry(state1: { retry: () => void }) { + assert.step('retry'); + return state1.retry(); + } + + await this.render( + + ); + + assert.equal(state2, getRequestState(request), 'state is a stable reference'); + assert.equal(counter, 1, 'counter is 1'); + assert.equal(this.element.textContent?.trim(), 'PendingCount: 1'); + + await request; + await rerender(); + + assert.equal(counter, 2, 'counter is 2'); + assert.equal(this.element.textContent?.trim(), 'Chris ThoburnCount: 2'); + + const request2 = this.manager.request({ url, method: 'GET' }); + source.request = request2; + + await rerender(); + + assert.equal(counter, 3, 'counter is 3'); + assert.equal(this.element.textContent?.trim(), 'Chris ThoburnCount: 3'); + }); + + test('externally retriggered request works as expected (store CacheHandler)', async function (assert) { + const store = this.owner.lookup('service:store') as Store; + const manager = new RequestManager(); + manager.use([new MockServerHandler(this), Fetch]); + manager.useCache(StoreHandler); + store.requestManager = manager; + this.manager = manager; + + registerDerivations(store.schema); + store.schema.registerResource( + withDefaults({ + type: 'user', + identity: { name: 'id', kind: '@id' }, + fields: [ + { + name: 'name', + kind: 'field', + }, + ], + }) + ); + type User = { + id: string; + name: string; + }; + + const url = await mockRetrySuccess(this); + const request = store.request>({ url, method: 'GET' }); + const state2 = getRequestState(request); + + class RequestSource { + @tracked request: Future> = request; + } + const source = new RequestSource(); + + let counter = 0; + function countFor(_result: unknown) { + return ++counter; + } + function retry(state1: { retry: () => void }) { + assert.step('retry'); + return state1.retry(); + } + + await this.render( + + ); + + assert.equal(state2, getRequestState(request), 'state is a stable reference'); + assert.equal(counter, 1, 'counter is 1'); + assert.equal(this.element.textContent?.trim(), 'PendingCount: 1'); + + await request; + await rerender(); + assert.equal(counter, 2, 'counter is 2'); + assert.equal(this.element.textContent?.trim(), 'Chris ThoburnCount: 2'); + + const request2 = store.request>({ url, method: 'GET' }); + source.request = request2; + + await rerender(); + + assert.equal(counter, 3, 'counter is 3'); + assert.equal(this.element.textContent?.trim(), 'Chris ThoburnCount: 3'); + + await request2; + await rerender(); + + assert.equal(counter, 3, 'counter is 3'); + assert.equal(this.element.textContent?.trim(), 'Chris ThoburnCount: 3'); + }); + test('it rethrows if error block is not present', async function (assert) { const url = await mockGETFailure(this); const request = this.manager.request({ url, method: 'GET' }); @@ -406,7 +540,7 @@ module('Integration | ', function (hooks) { assert.true(state.error instanceof Error, 'error is an instance of Error'); assert.equal( (state.error as Error | undefined)?.message, - '[404 Not Found] GET (cors) - https://localhost:1135/users/2', + `[404 Not Found] GET (cors) - ${url}`, 'error message is correct' ); assert.equal(counter, 1, 'counter is still 1'); @@ -647,27 +781,21 @@ module('Integration | ', function (hooks) { assert.true(state.error instanceof Error, 'error is an instance of Error'); assert.equal( (state.error as Error | undefined)?.message, - '[404 Not Found] GET (cors) - https://localhost:1135/users/2', + `[404 Not Found] GET (cors) - ${url}`, 'error message is correct' ); assert.equal(counter, 1, 'counter is 1'); - assert.equal( - this.element.textContent?.trim(), - '[404 Not Found] GET (cors) - https://localhost:1135/users/2Count: 1' - ); + assert.equal(this.element.textContent?.trim(), `[404 Not Found] GET (cors) - ${url}Count: 1`); await rerender(); assert.equal(state.result, null, 'after rerender result is still null'); assert.true(state.error instanceof Error, 'error is an instance of Error'); assert.equal( (state.error as Error | undefined)?.message, - '[404 Not Found] GET (cors) - https://localhost:1135/users/2', + `[404 Not Found] GET (cors) - ${url}`, 'error message is correct' ); assert.equal(counter, 1, 'counter is 1'); - assert.equal( - this.element.textContent?.trim(), - '[404 Not Found] GET (cors) - https://localhost:1135/users/2Count: 1' - ); + assert.equal(this.element.textContent?.trim(), `[404 Not Found] GET (cors) - ${url}Count: 1`); }); test('isOnline updates when expected', async function (assert) {