Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 51 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# @adobe/data

Adobe Data Oriented Programming Library

## Documentation
Expand Down Expand Up @@ -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
Expand All @@ -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?

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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`.
38 changes: 37 additions & 1 deletion packages/data/src/service/async-data-service/create-lazy.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,47 @@ import { AsyncDataService } from "@adobe/data/service";
```typescript
AsyncDataService.createLazy(
load: (...args: any[]) => Promise<Service>,
properties: { [key: string]: PropertyDescriptor }
properties: { [key: string]: PropertyDescriptor },
options?: CreateLazyOptions
): (...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.

### Options

```typescript
interface CreateLazyOptions {
// Optional preload scheduler — opts a call site into eager warm-up.
preload?: (warm: () => void) => void;
}
```

By default a lazy service loads only when its first property is accessed. That is ideal for keeping heavy imports out of the initial bundle, but it means the very first `send` / read / call pays for the load — and if that first touch happens right before the page tears down (e.g. an analytics event fired immediately before navigation), the load can lose the race.

`options.preload` closes that gap without giving up laziness. It is invoked **once** when the factory creates an instance, receiving a `warm` callback that eagerly triggers the real load. The **caller owns the policy** (when to warm); `createLazy` owns the **mechanism** (invoking `load` and memoizing). Because loading is idempotent, `warm` is safe to call any number of times and dedupes with the first real property access.

For the common browser case, pass the ready-made `AsyncDataService.preloadWhenIdle` scheduler — it warms the service via the browser's `requestIdleCallback` (with a `setTimeout` fallback) and is a no-op outside a browser:

```typescript
import { AsyncDataService } from "@adobe/data/service";

// Warm the service when the browser is idle — so the first real call never races a cold load.
const createLazyAnalytics = AsyncDataService.createLazy(
() => import('./analytics').then(m => m.create()),
{ send: 'fn:void', pageload: 'fn:void' },
{ preload: AsyncDataService.preloadWhenIdle }
);
```

For a different policy (eager, after first input, etc.) pass your own scheduler — `preload` is any `(warm: () => void) => void`:

```typescript
{ preload: (warm) => myScheduler(warm) }
```

The scheduler runs synchronously during factory creation. Omit `options` (or `options.preload`) to keep the fully-lazy behavior. If the scheduler can throw, it should swallow its own errors, since the throw would otherwise propagate to the caller creating the instance (`preloadWhenIdle` already does).

### Descriptor Format

```typescript
Expand Down Expand Up @@ -227,6 +262,7 @@ type CheckValidDataService = Assert<AsyncDataService.IsValid<MyService>>;
## 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
117 changes: 113 additions & 4 deletions packages/data/src/service/async-data-service/create-lazy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -856,20 +856,20 @@ describe('createLazy', () => {
interface TestService extends Service {
track: (event: string) => void;
}

const factory = createLazy(
() => Promise.resolve({
serviceName: 'test-service',
track: (event: string) => {}
}),
{ 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',
Expand All @@ -878,3 +878,112 @@ describe('createLazy', () => {
});
});
});

// ============================================================================
// PRELOAD OPTION TESTS
// ============================================================================

describe('createLazy preload option', () => {
interface TestService extends Service {
track: (event: string) => void;
}

const makeLoader = (loadCount: { value: number }) => (): Promise<TestService> => {
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('invokes the preload scheduler with a warm callback on instance creation', async () => {
const loadCount = { value: 0 };
let warm: (() => void) | undefined;
const factory = createLazy(makeLoader(loadCount), { track: 'fn:void' }, {
preload: (w) => { warm = w; }
});

factory();

assert({
given: 'a factory instance created with a preload scheduler',
should: 'invoke the scheduler with a warm callback but not load yet',
actual: `${typeof warm}:${loadCount.value}`,
expected: 'function: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
});
});

test('queued calls still run after a warm preload', async () => {
const events: string[] = [];
const factory = createLazy(
() => Promise.resolve({
serviceName: 'test-service',
track: (event: string) => { events.push(event); }
}),
{ track: 'fn:void' },
{ preload: (warm) => { warm(); } }
);

const service = factory();
service.track('event-1');
service.track('event-2');
await new Promise(resolve => setTimeout(resolve, 10));

assert({
given: 'calls made on a preloaded lazy service',
should: 'execute all calls in order once loaded',
actual: events.join(','),
expected: 'event-1,event-2'
});
});
});
48 changes: 38 additions & 10 deletions packages/data/src/service/async-data-service/create-lazy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,36 @@ type InferService<F> =
? S extends Service ? S : never
: never;

// Extract Args type from load function
type InferArgs<F> =
F extends () => Promise<any>
? void
: F extends (args: infer A) => Promise<any>
? A
// Extract Args type from load function
type InferArgs<F> =
F extends () => Promise<any>
? void
: F extends (args: infer A) => Promise<any>
? A
: never;

// ============================================================================
// OPTIONS
// ============================================================================

export interface CreateLazyOptions {
/**
* Optional preload scheduler. Invoked once when the factory creates a lazy service instance,
* receiving a `warm` callback that eagerly loads the real service — constructing it before its
* first property is touched, so the first `send` / read / call never races a cold load.
*
* The caller owns the *policy* (when to warm — e.g. at browser idle, eagerly, or after first
* input); `createLazy` owns the *mechanism* (invoking `load` and memoizing). Loading is
* idempotent, so calling `warm` any number of times is safe and dedupes with the first real
* property access.
*
* Omit it (the default) to keep the fully-lazy behavior: the service loads only on first touch.
* The scheduler runs synchronously during factory creation; if it throws, the throw propagates to
* the caller, so a scheduler that can fail should swallow its own errors.
*/
readonly preload?: (warm: () => void) => void;
}

// ============================================================================
// MAIN FUNCTION SIGNATURE
// ============================================================================
Expand All @@ -49,6 +71,7 @@ type InferArgs<F> =
*
* @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; `options.preload` opts a call site into eager warm-up (see CreateLazyOptions)
* @returns A factory function that creates lazy service instances
*
* TypeScript will enforce:
Expand Down Expand Up @@ -78,10 +101,11 @@ export function createLazy<
>(
load: LoadFn,
properties: {
[K in Exclude<keyof InferService<LoadFn>, keyof Service>]:
[K in Exclude<keyof InferService<LoadFn>, keyof Service>]:
PropertyDescriptor<InferService<LoadFn>[K]>
}
): InferArgs<LoadFn> extends void
},
options?: CreateLazyOptions
): InferArgs<LoadFn> extends void
? () => InferService<LoadFn>
: (args: InferArgs<LoadFn>) => InferService<LoadFn> {

Expand All @@ -108,7 +132,11 @@ export function createLazy<

return loadPromise!;
};


// Hand the preload scheduler a warm-up trigger that eagerly loads the real service. The scheduler
// decides when to fire it; ensureLoading memoizes, so this dedupes with the first property touch.
options?.preload?.(() => { void ensureLoading(); });

// Build lazy service object
const lazyService: any = {
serviceName: 'lazy-service',
Expand Down
Loading