diff --git a/README.md b/README.md index c9fb0120..d115e5c8 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # @adobe/data + Adobe Data Oriented Programming Library ## Documentation @@ -116,6 +117,7 @@ For our purposes, `Data` is immutable `JSON` (de)serializable objects and primit ### Why immutable Data? We prefer Data because it is easy to: + - serialize - deserialize - inspect @@ -124,14 +126,15 @@ We prefer Data because it is easy to: - validate We prefer immutable Data because it is easy to: + - reason about - avoid side effects - avoid defensive copying - use for pure function arguments and return values - use with concurrency - memoize results - - use as cache key (stringified) - - use as cache value + - use as cache key (stringified) + - use as cache value ### What is Data Oriented Design? @@ -191,10 +194,11 @@ export type Unobserve = () => void; ``` An Observable can be thought of sort of like a Promise but with a few important differences. + - A Promise only yields a single value, an Observable yields a sequence of values. - A Promise can reject with an error, an Observable can not. (It could yield type `MyResult | MyError` though.) - A Promise can only resolve asynchronously, an Observable *may* yield an initial result synchronously. - - An Observable is allowed to call the Callback callback function immediately upon observation if it has a value. + - An Observable is allowed to call the Callback callback function immediately upon observation if it has a value. - A Promise begins executing immediately, an Observable may lazily wait for a first observer before taking any action. - An Observable can be unobserved. @@ -260,6 +264,7 @@ The `BlobStore` interface and corresponding `blobStore` exported instance provid `BlobRef`s have a number of advantages over directly using blobs. `BlobRef`s are: + - small json objects - deterministic for each `Blob` based on mime type and content. - suitable for persistence to locations with limited size. @@ -302,3 +307,46 @@ Sanders Mertens covers this thoroughly in his ECS FAQ: [https://github.com/SanderMertens/ecs-faq?tab=readme-ov-file#what-is-ecs](https://github.com/SanderMertens/ecs-faq?tab=readme-ov-file#what-is-ecs) In addition to the standard Entity, Component, and System definitions, we also use the term **Resource** — a global singleton value defined on the ECS itself, not attached to any specific entity. + +## Corporate contributors + +If your primary GitHub account is a corporate / Enterprise Managed User (EMU) account, it may be unable to fork or push to public repositories like this one. The fix is to contribute from a separate personal GitHub identity on the same machine, using SSH host aliases so each identity uses its own key. + +**Problem:** one machine, multiple GitHub identities (e.g. corporate vs. personal), needing a different SSH key per identity — but `git@github.com` only supports one key per config entry. + +**Solution:** define extra `Host` aliases in `~/.ssh/config` that all point at the real `github.com`, each pinned to a different key with `IdentitiesOnly yes`: + +```sshconfig +# Default identity (e.g. your corporate account) +Host github.com + HostName github.com + AddKeysToAgent yes + UseKeychain yes + IdentityFile ~/.ssh/id_ed25519 + IdentitiesOnly yes + +# Alternate identity (e.g. your personal account) +Host github.com-personal + HostName github.com + AddKeysToAgent yes + UseKeychain yes + IdentityFile ~/.ssh/id_ed25519_personal + IdentitiesOnly yes +``` + +Then pick the identity by swapping the host in the remote URL: + +```sh +git@github.com-personal:adobe/data.git # uses the personal-account key +git@github.com:adobe/data.git # uses the default key +``` + +`IdentitiesOnly yes` is required — without it, `ssh-agent` may offer the wrong key first and authenticate against the wrong account. + +**Setup:** + +1. Generate a separate SSH key per GitHub identity and add each public key to the corresponding GitHub account. +2. Add one host-alias block per non-default identity to `~/.ssh/config`, as above. +3. For any repo that should use a non-default identity, set its remote to the aliased host (`git remote set-url origin git@github.com-personal:adobe/data.git`), and set that repo's local `user.email` to the matching account. + +The `-personal` suffix is just an arbitrary alias name — any label works as long as `HostName` maps it back to `github.com`. diff --git a/packages/data/src/service/async-data-service/create-lazy.md b/packages/data/src/service/async-data-service/create-lazy.md index a6c01615..35cf9b1a 100644 --- a/packages/data/src/service/async-data-service/create-lazy.md +++ b/packages/data/src/service/async-data-service/create-lazy.md @@ -15,12 +15,29 @@ import { AsyncDataService } from "@adobe/data/service"; ```typescript AsyncDataService.createLazy( load: (...args: any[]) => Promise, - properties: { [key: string]: PropertyDescriptor } + properties: { [key: string]: PropertyDescriptor }, + options?: { preload?: (warm: () => void) => void } ): (...args: Args) => Service ``` Returns a **factory function** that creates lazy service instances. TypeScript automatically infers both the service type and argument types from the `load` function. +### Preloading + +By default a lazy service loads on first property access. Pass `options.preload` to warm it earlier: a scheduler that receives a `warm` callback which triggers the load (idempotent — it dedupes with the first real access). For the common browser case use the built-in idle scheduler: + +```typescript +import { AsyncDataService } from "@adobe/data/service"; + +AsyncDataService.createLazy( + () => import('./analytics').then(m => m.create()), + { send: 'fn:void', pageload: 'fn:void' }, + { preload: AsyncDataService.preloadWhenIdle } // warm at browser idle +); +``` + +`preloadWhenIdle` is browser-only (uses `requestIdleCallback`); pass your own `(warm) => void` scheduler for any other policy. + ### Descriptor Format ```typescript @@ -227,6 +244,7 @@ type CheckValidDataService = Assert>; ## Testing See `create-lazy.test.ts` for comprehensive type safety tests including: + - Valid usage with all property types - Error cases for missing/wrong/extra properties - Services with and without constructor args diff --git a/packages/data/src/service/async-data-service/create-lazy.test.ts b/packages/data/src/service/async-data-service/create-lazy.test.ts index 077689b1..24c9c91a 100644 --- a/packages/data/src/service/async-data-service/create-lazy.test.ts +++ b/packages/data/src/service/async-data-service/create-lazy.test.ts @@ -856,7 +856,7 @@ describe('createLazy', () => { interface TestService extends Service { track: (event: string) => void; } - + const factory = createLazy( () => Promise.resolve({ serviceName: 'test-service', @@ -864,12 +864,12 @@ describe('createLazy', () => { }), { track: 'fn:void' } ); - + const service = factory(); - + const fn1 = service.track; const fn2 = service.track; - + assert({ given: 'void function property is accessed multiple times', should: 'return the same function instance', @@ -878,3 +878,71 @@ describe('createLazy', () => { }); }); }); + +// ============================================================================ +// PRELOAD OPTION TESTS +// ============================================================================ + +describe('createLazy preload option', () => { + interface TestService extends Service { + track: (event: string) => void; + } + + const makeLoader = (loadCount: { value: number }) => (): Promise => { + loadCount.value += 1; + return Promise.resolve({ + serviceName: 'test-service', + track: (_event: string) => {} + }); + }; + + test('does not load when no preload option is given', async () => { + const loadCount = { value: 0 }; + const factory = createLazy(makeLoader(loadCount), { track: 'fn:void' }); + + factory(); + await new Promise(resolve => setTimeout(resolve, 10)); + + assert({ + given: 'a factory instance created without a preload scheduler', + should: 'not load the real service until a property is touched', + actual: loadCount.value, + expected: 0 + }); + }); + + test('warm callback eagerly loads the real service before any property touch', async () => { + const loadCount = { value: 0 }; + const factory = createLazy(makeLoader(loadCount), { track: 'fn:void' }, { + preload: (warm) => { warm(); } + }); + + factory(); + await new Promise(resolve => setTimeout(resolve, 10)); + + assert({ + given: 'a preload scheduler that fires warm immediately', + should: 'load the real service without any property being touched', + actual: loadCount.value, + expected: 1 + }); + }); + + test('warm dedupes with the first real property access', async () => { + const loadCount = { value: 0 }; + const factory = createLazy(makeLoader(loadCount), { track: 'fn:void' }, { + preload: (warm) => { warm(); warm(); } + }); + + const service = factory(); + service.track('event-1'); + await new Promise(resolve => setTimeout(resolve, 10)); + + assert({ + given: 'warm called multiple times and then a real property touch', + should: 'load the real service exactly once', + actual: loadCount.value, + expected: 1 + }); + }); +}); diff --git a/packages/data/src/service/async-data-service/create-lazy.ts b/packages/data/src/service/async-data-service/create-lazy.ts index b677daf5..0e796c72 100644 --- a/packages/data/src/service/async-data-service/create-lazy.ts +++ b/packages/data/src/service/async-data-service/create-lazy.ts @@ -30,12 +30,12 @@ type InferService = ? S extends Service ? S : never : never; -// Extract Args type from load function -type InferArgs = - F extends () => Promise - ? void - : F extends (args: infer A) => Promise - ? A +// Extract Args type from load function +type InferArgs = + F extends () => Promise + ? void + : F extends (args: infer A) => Promise + ? A : never; // ============================================================================ @@ -49,6 +49,7 @@ type InferArgs = * * @param load - Function that loads and returns the real service (may accept args) * @param properties - Object describing how to wrap each service property + * @param options - Optional settings; `preload` warms the service early via a caller-supplied scheduler * @returns A factory function that creates lazy service instances * * TypeScript will enforce: @@ -78,10 +79,11 @@ export function createLazy< >( load: LoadFn, properties: { - [K in Exclude, keyof Service>]: + [K in Exclude, keyof Service>]: PropertyDescriptor[K]> - } -): InferArgs extends void + }, + options?: { preload?: (warm: () => void) => void } +): InferArgs extends void ? () => InferService : (args: InferArgs) => InferService { @@ -108,7 +110,10 @@ export function createLazy< return loadPromise!; }; - + + // Optional early warm-up; ensureLoading memoizes, so it dedupes with the first property touch. + options?.preload?.(() => { void ensureLoading(); }); + // Build lazy service object const lazyService: any = { serviceName: 'lazy-service', diff --git a/packages/data/src/service/async-data-service/preload-when-idle.ts b/packages/data/src/service/async-data-service/preload-when-idle.ts new file mode 100644 index 00000000..6366da07 --- /dev/null +++ b/packages/data/src/service/async-data-service/preload-when-idle.ts @@ -0,0 +1,15 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +/** + * Browser-only `preload` scheduler for {@link createLazy}: warms the service when the browser next + * goes idle, via the browser's `requestIdleCallback`. No-op where that is unavailable (SSR / Node / + * older browsers). + * + * ```typescript + * createLazy(load, properties, { preload: AsyncDataService.preloadWhenIdle }); + * ``` + */ +export function preloadWhenIdle(warm: () => void): void { + const idle = (globalThis as { requestIdleCallback?: (callback: () => void) => void }).requestIdleCallback; + if (typeof idle === 'function') idle(warm); +} diff --git a/packages/data/src/service/async-data-service/public.ts b/packages/data/src/service/async-data-service/public.ts index 05f53ba6..cc8858df 100644 --- a/packages/data/src/service/async-data-service/public.ts +++ b/packages/data/src/service/async-data-service/public.ts @@ -2,3 +2,4 @@ export * from "./is-valid.js"; export * from "./create-lazy.js"; +export { preloadWhenIdle } from "./preload-when-idle.js";