assign-gingerly 0.0.85 → 0.0.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/DX/emojis.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * import { builtInEmoji } from 'assign-gingerly/builtInEmoji.js';
8
8
  *
9
9
  * assignFrom(target, {
10
- * '?.el =>': { do: '🔗', get: { value: ['?.first', ' ', '?.last'] } }
10
+ * '?.el =>': { do: '🏷️', get: { template: [...] } }
11
11
  * }, { from: vm, handlers: builtInEmoji });
12
12
  */
13
13
  /**
@@ -17,14 +17,16 @@
17
17
  * |-------|---------|
18
18
  * | 📦 | builtIns.lazyLoad |
19
19
  * | 🎚️ | builtIns.lazyLoadSwitch |
20
- * | 🔗 | builtIns.join |
21
20
  * | 🏷️ | builtIns.microDataJoin |
22
21
  * | 📋 | builtIns.manageTemplateList |
22
+ *
23
+ * `join` is no longer a `do:` handler — it moved to the synchronous ` =&` operator
24
+ * (see syncOps/join.ts) and isn't looked up through options.handlers, so it has no
25
+ * emoji alias here.
23
26
  */
24
27
  export const builtInEmoji = {
25
28
  '📦': 'builtIns.lazyLoad',
26
29
  '🎚️': 'builtIns.lazyLoadSwitch',
27
- '🔗': 'builtIns.join',
28
30
  '🏷️': 'builtIns.microDataJoin',
29
31
  '📋': 'builtIns.manageTemplateList',
30
32
  '📊': 'builtIns.rangeSelector',
package/DX/emojis.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * import { builtInEmoji } from 'assign-gingerly/builtInEmoji.js';
8
8
  *
9
9
  * assignFrom(target, {
10
- * '?.el =>': { do: '🔗', get: { value: ['?.first', ' ', '?.last'] } }
10
+ * '?.el =>': { do: '🏷️', get: { template: [...] } }
11
11
  * }, { from: vm, handlers: builtInEmoji });
12
12
  */
13
13
 
@@ -18,14 +18,16 @@
18
18
  * |-------|---------|
19
19
  * | 📦 | builtIns.lazyLoad |
20
20
  * | 🎚️ | builtIns.lazyLoadSwitch |
21
- * | 🔗 | builtIns.join |
22
21
  * | 🏷️ | builtIns.microDataJoin |
23
22
  * | 📋 | builtIns.manageTemplateList |
23
+ *
24
+ * `join` is no longer a `do:` handler — it moved to the synchronous ` =&` operator
25
+ * (see syncOps/join.ts) and isn't looked up through options.handlers, so it has no
26
+ * emoji alias here.
24
27
  */
25
28
  export const builtInEmoji= {
26
29
  '📦': 'builtIns.lazyLoad',
27
30
  '🎚️': 'builtIns.lazyLoadSwitch',
28
- '🔗': 'builtIns.join',
29
31
  '🏷️': 'builtIns.microDataJoin',
30
32
  '📋': 'builtIns.manageTemplateList',
31
33
  '📊': 'builtIns.rangeSelector',
package/DX/paths.js CHANGED
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * const $ = paths<Person>();
17
17
  *
18
- * // Use sp (split into parts) to create arrays for builtIns.join:
18
+ * // Use sp (split into parts) to create arrays for the ' =&' join op:
19
19
  * const value = sp`${$.lastName}, ${$.firstName}`;
20
20
  * // ['?.lastName', ', ', '?.firstName']
21
21
  *
@@ -38,6 +38,7 @@ const COMMAND_TOKEN_SUFFIXES = {
38
38
  YEq: ' Y=',
39
39
  MinusEq: ' -=',
40
40
  Arrow: ' =>',
41
+ EqAmp: ' =&',
41
42
  };
42
43
  function serializePath(prefix) {
43
44
  return prefix.length > 0 ? `?.${prefix}` : '?.';
@@ -287,6 +288,9 @@ export function smoothOver(value) {
287
288
  export function doAssign(...pairs) {
288
289
  return { assign: Object.assign({}, ...pairs) };
289
290
  }
291
+ export function assign(...pairs) {
292
+ return Object.assign({}, ...pairs);
293
+ }
290
294
  /**
291
295
  * Compile-time loop expansion: generates one entry per key from a factory function.
292
296
  * Creates a typed proxy internally — the factory receives both the key and the proxy.
@@ -321,7 +325,7 @@ export function forEachKeyIn(keys, factory, options) {
321
325
  * string representation — no `.Path` call needed inside sp template literals.
322
326
  *
323
327
  * Arrays passed as interpolations are preserved as nested arrays (for
324
- * all-or-nothing optional segments in builtIns.join).
328
+ * all-or-nothing optional segments in the ' =&' join op).
325
329
  *
326
330
  * @example
327
331
  * const $ = paths<Person>();
package/DX/paths.ts CHANGED
@@ -15,12 +15,12 @@
15
15
  *
16
16
  * const $ = paths<Person>();
17
17
  *
18
- * // Use sp (split into parts) to create arrays for builtIns.join:
18
+ * // Use sp (split into parts) to create arrays for the ' =&' join op:
19
19
  * const value = sp`${$.lastName}, ${$.firstName}`;
20
20
  * // ['?.lastName', ', ', '?.firstName']
21
21
  *
22
- * // Use .Path for raw string contexts (object keys, plain arrays):
23
- * const key = $.textContent.Path; // '?.textContent'
22
+ * // Use .Path for raw string contexts (object keys, plain arrays):
23
+ * const key = $.textContent.Path; // '?.textContent'
24
24
  */
25
25
 
26
26
  /**
@@ -41,6 +41,7 @@ const COMMAND_TOKEN_SUFFIXES = {
41
41
  YEq: ' Y=',
42
42
  MinusEq: ' -=',
43
43
  Arrow: ' =>',
44
+ EqAmp: ' =&',
44
45
  } as const;
45
46
 
46
47
  type CommandToken = keyof typeof COMMAND_TOKEN_SUFFIXES;
@@ -79,6 +80,7 @@ export type PathProxyCore = {
79
80
  readonly YEq: PathProxy<any>;
80
81
  readonly MinusEq: PathProxy<any>;
81
82
  readonly Arrow: PathProxy<any>;
83
+ readonly EqAmp: PathProxy<any>;
82
84
  };
83
85
 
84
86
  export type PathProxy<T> = {
@@ -102,25 +104,25 @@ export interface PathsOptions {
102
104
  /**
103
105
  * Create a proxy for id-ref paths (#[varName]).
104
106
  * After the initial #[varName], further property access chains with ?. from the resolved element.
105
- * .Path returns the #[varName] prefix (optionally with further ?. path).
107
+ * .Path returns the #[varName] prefix (optionally with further ?. path).
106
108
  */
107
109
  function createIdRefProxy(idRef: string, options?: PathsOptions): any {
108
110
  function handler() {}
109
111
  Object.defineProperty(handler, PATH_SYMBOL, { value: idRef });
110
112
  return new Proxy(handler, {
111
113
  get(_, prop: string | symbol) {
112
- if (prop === 'Path' || prop === PATH_SYMBOL) {
113
- return idRef;
114
- }
114
+ if (prop === 'Path' || prop === PATH_SYMBOL) {
115
+ return idRef;
116
+ }
115
117
  if (typeof prop === 'symbol') return undefined;
116
118
 
117
119
  const reservedToken = getReservedToken(prop);
118
120
  if (reservedToken === 'Each') {
119
121
  return createIdRefProxy(appendPathSegment(idRef, '@each'), options);
120
122
  }
121
- if (reservedToken && reservedToken !== 'Path') {
122
- return createIdRefProxy(appendCommandSuffix(idRef, reservedToken), options);
123
- }
123
+ if (reservedToken && reservedToken !== 'Path') {
124
+ return createIdRefProxy(appendCommandSuffix(idRef, reservedToken), options);
125
+ }
124
126
 
125
127
  // Chain further path segments after the id ref
126
128
  const chained = appendPathSegment(idRef, String(prop));
@@ -162,16 +164,16 @@ function createPathProxy(prefix: string, options?: PathsOptions): any {
162
164
 
163
165
  return new Proxy(handler, {
164
166
  get(_, prop: string | symbol) {
165
- if (prop === 'Path' || prop === PATH_SYMBOL) {
166
- return serializePath(prefix);
167
- }
167
+ if (prop === 'Path' || prop === PATH_SYMBOL) {
168
+ return serializePath(prefix);
169
+ }
168
170
  // Ignore symbol access (Symbol.iterator, Symbol.toPrimitive, etc.)
169
171
  if (typeof prop === 'symbol') return undefined;
170
172
 
171
173
  const reservedToken = getReservedToken(String(prop));
172
- if (reservedToken === 'Path') {
173
- return serializePath(prefix);
174
- }
174
+ if (reservedToken === 'Path') {
175
+ return serializePath(prefix);
176
+ }
175
177
  if (reservedToken === 'Each') {
176
178
  return createPathProxy(appendPathSegment(prefix, '@each'), options);
177
179
  }
@@ -231,14 +233,14 @@ function createPathProxy(prefix: string, options?: PathsOptions): any {
231
233
  *
232
234
  * @example
233
235
  * const $ = paths<Person>();
234
- * $.lastName.Path // '?.lastName'
235
- * $.address.city.Path // '?.address?.city'
236
+ * $.lastName.Path // '?.lastName'
237
+ * $.address.city.Path // '?.address?.city'
236
238
  *
237
239
  * // With aka (reverse alias applied):
238
240
  * const $ = paths<MyEl>({ aka: { q: 'querySelector' } });
239
- * $.querySelector('.user').textContent.Path // '?.q?..user?.textContent'
241
+ * $.querySelector('.user').textContent.Path // '?.q?..user?.textContent'
240
242
  *
241
- * // Inside sp template literals, .Path is not needed:
243
+ * // Inside sp template literals, .Path is not needed:
242
244
  * sp`${$.lastName}, ${$.firstName}` // ['?.lastName', ', ', '?.firstName']
243
245
  */
244
246
  export function paths<T>(options?: PathsOptions): PathProxy<T> {
@@ -246,7 +248,7 @@ export function paths<T>(options?: PathsOptions): PathProxy<T> {
246
248
  }
247
249
 
248
250
  /**
249
- * Create an assignment pair: { [lhs.Path]: rhs.Path }.
251
+ * Create an assignment pair: { [lhs.Path]: rhs.Path }.
250
252
  * Used to express "set this target to this source value" in a spreadable form.
251
253
  *
252
254
  * @example
@@ -337,6 +339,10 @@ export function doAssign(...pairs: Record<string, any>[]): { assign: Record<stri
337
339
  return { assign: Object.assign({}, ...pairs) };
338
340
  }
339
341
 
342
+ export function assign(...pairs: Record<string, any>[]): Record<string, any>{
343
+ return Object.assign({}, ...pairs);
344
+ }
345
+
340
346
  /**
341
347
  * Compile-time loop expansion: generates one entry per key from a factory function.
342
348
  * Creates a typed proxy internally — the factory receives both the key and the proxy.
@@ -373,10 +379,10 @@ export function forEachKeyIn<T>(
373
379
  * Interleaves static string segments with interpolated values.
374
380
  *
375
381
  * Path proxy objects are auto-detected and converted to their `?.`-prefixed
376
- * string representation — no `.Path` call needed inside sp template literals.
382
+ * string representation — no `.Path` call needed inside sp template literals.
377
383
  *
378
384
  * Arrays passed as interpolations are preserved as nested arrays (for
379
- * all-or-nothing optional segments in builtIns.join).
385
+ * all-or-nothing optional segments in the ' =&' join op).
380
386
  *
381
387
  * @example
382
388
  * const $ = paths<Person>();
package/README.md CHANGED
@@ -851,7 +851,8 @@ While we are in the business of passing values of object A into object B, we mig
851
851
  | ` -=` | Delete | Remove properties from an object | `'?.data -=': 'key'` |
852
852
  | ` Y=` | Merge | Recursively `assignGingerly` into a sub-object | `'style Y=': { width: '100px' }` |
853
853
  | ` ?=` | Ternary | Conditional assignment (assignFrom only) — [details](docs/ternary-assignment.md) | `'?.text ?=': ['?.cond', 'yes', 'no']` |
854
- | ` =>` | Handler | Invoke a handler plugin (assignFrom only) | `'?.el =>': { do: 'builtIns.join', ... }` |
854
+ | ` =>` | Handler | Invoke a handler plugin (assignFrom only) | `'?.el =>': { do: 'builtIns.lazyLoad', ... }` |
855
+ | ` =&` | Sync op | Compute a value synchronously and assign it back (assignFrom only) — no dynamic import, no await, ever | `'?.text =&': { join: ['?.first', ' ', '?.last'] }` |
855
856
 
856
857
  All operators use a space before the suffix to distinguish them from property names. They compose with `?.` nested paths and `withMethods`.
857
858
 
@@ -2691,16 +2692,18 @@ await assignFromAsync(myElement, {
2691
2692
 
2692
2693
  Import paths must be local (relative, absolute, or bare specifiers — no cross-domain URLs). The module's default export is checked first; otherwise the first exported class with an `assign` method is used.
2693
2694
 
2694
- Built-in handlers (`builtIns.lazyLoad`, `builtIns.join`, etc.) auto-load without needing to be listed in `handlers`.
2695
+ Built-in handlers (`builtIns.lazyLoad`, `builtIns.microDataJoin`, etc.) auto-load without needing to be listed in `handlers`.
2696
+
2697
+ > `join` isn't in this list — it's a synchronous [` =&` op](#sync-op-join) now, not an async `do:` handler, so it's never dynamically imported and never awaited.
2695
2698
 
2696
2699
  **Handler aliases:** The `handlers` option also accepts built-in names as values, allowing you to define short aliases (including emoji) for concise configs:
2697
2700
 
2698
2701
  ```JavaScript
2699
2702
  import { builtInEmoji } from 'assign-gingerly/builtInEmoji.js';
2700
- // { '📦': 'builtIns.lazyLoad', '🎚️': 'builtIns.lazyLoadSwitch', '🔗': 'builtIns.join', '🏷️': 'builtIns.microDataJoin', '📋': 'builtIns.manageTemplateList' }
2703
+ // { '📦': 'builtIns.lazyLoad', '🎚️': 'builtIns.lazyLoadSwitch', '🏷️': 'builtIns.microDataJoin', '📋': 'builtIns.manageTemplateList' }
2701
2704
 
2702
2705
  assignFrom(target, {
2703
- '?.textContent =>': { do: '🔗', get: { value: ['?.first', ' ', '?.last'] } }
2706
+ '?.el =>': { do: '🏷️', get: { template: [...] } }
2704
2707
  }, { from: vm, handlers: builtInEmoji });
2705
2708
 
2706
2709
  // Or define your own:
@@ -2960,9 +2963,15 @@ await assignFromAsync(document.body, {
2960
2963
  - Mixed `do` values — fully supported, each handler is looked up independently.
2961
2964
  - Error handling — fail-fast. If a handler throws, remaining handlers are skipped.
2962
2965
 
2963
- ### Built-in handler: `builtIns.join`
2966
+ **Return-value protocol:**
2967
+
2968
+ When a handler's `assign()` method returns a non-`undefined` value, `processHandlerCommands` assigns it back to the LHS path. Handlers like `builtIns.lazyLoad` return `undefined` (void) and operate by side effects instead.
2969
+
2970
+ ### Sync op: join (` =&`)
2964
2971
 
2965
- Joins a resolved array into a single string. Supports nested sub-arrays with "all-or-nothing" semantics for optional segments. Uses the **return-value protocol** the handler returns the joined string, which `processHandlerCommands` assigns back to the LHS path.
2972
+ ` =&` is a separate operator from ` =>`, for computed values that have nothing to await no dynamic import, no handler class, no microtask hop, ever. The RHS names exactly one op from a small built-in registry (`syncOps/registry.ts`); sibling keys are that op's own config. `join` is the first (and so far only) op in the registry.
2973
+
2974
+ `join` resolves an array of `?.` paths and literals, then joins it into a single string. It supports nested sub-arrays with "all-or-nothing" semantics for optional segments.
2966
2975
 
2967
2976
  ```JavaScript
2968
2977
  const vm = {
@@ -2971,11 +2980,8 @@ const vm = {
2971
2980
  };
2972
2981
 
2973
2982
  assignFrom(oElement, {
2974
- '?.textContent =>': {
2975
- do: 'builtIns.join',
2976
- get: {
2977
- value: ['?.lastName', ', ', '?.firstName']
2978
- }
2983
+ '?.textContent =&': {
2984
+ join: ['?.lastName', ', ', '?.firstName']
2979
2985
  }
2980
2986
  }, { from: vm });
2981
2987
 
@@ -2984,11 +2990,11 @@ assignFrom(oElement, {
2984
2990
 
2985
2991
  **How it works:**
2986
2992
 
2987
- 1. The `get.value` array is resolved by `getValues` — `?.` path strings are replaced with actual values from `options.from`.
2988
- 2. Top-level `null`/`undefined` values are filtered out.
2993
+ 1. The whole config object (`{ join: [...], separator: '...' }`) is resolved in one pass by `getValues` — `?.` path strings anywhere inside it, including nested inside the `join` array, are replaced with actual values from `options.from`.
2994
+ 2. Top-level `null`/`undefined` values in the resolved array are filtered out.
2989
2995
  3. Nested sub-arrays use **all-or-nothing** semantics: if any element in a sub-array resolves to `null`/`undefined`, the entire sub-array is dropped.
2990
- 4. Remaining elements are joined with the separator (default: `''`, empty string).
2991
- 5. The joined string is returned and assigned to the LHS path.
2996
+ 4. Remaining elements are joined with `separator` (default: `''`, empty string).
2997
+ 5. The joined string is assigned straight to the LHS path — every sync op always produces a value to assign; there's no opt-in return-value protocol to it like ` =>` has.
2992
2998
 
2993
2999
  **Optional segments with nested arrays:**
2994
3000
 
@@ -3000,11 +3006,8 @@ const vm = {
3000
3006
  };
3001
3007
 
3002
3008
  assignFrom(oElement, {
3003
- '?.textContent =>': {
3004
- do: 'builtIns.join',
3005
- get: {
3006
- value: ['?.lastName', [', ', '?.middleName'], ', ', '?.firstName']
3007
- }
3009
+ '?.textContent =&': {
3010
+ join: ['?.lastName', [', ', '?.middleName'], ', ', '?.firstName']
3008
3011
  }
3009
3012
  }, { from: vm });
3010
3013
 
@@ -3019,21 +3022,16 @@ assignFrom(oElement, {
3019
3022
 
3020
3023
  ```JavaScript
3021
3024
  assignFrom(oElement, {
3022
- '?.textContent =>': {
3023
- do: 'builtIns.join',
3024
- get: {
3025
- value: ['?.firstName', '?.lastName'],
3026
- separator: ' | '
3027
- }
3025
+ '?.textContent =&': {
3026
+ join: ['?.firstName', '?.lastName'],
3027
+ separator: ' | '
3028
3028
  }
3029
3029
  }, { from: vm });
3030
3030
 
3031
3031
  // oElement.textContent = 'Helaena | Targaryen'
3032
3032
  ```
3033
3033
 
3034
- **Return-value protocol:**
3035
-
3036
- When a handler's `assign()` method returns a non-`undefined` value, `processHandlerCommands` assigns it back to the LHS path. This is how `builtIns.join` sets `textContent` — the handler computes the string and returns it. Existing handlers like `builtIns.lazyLoad` return `undefined` (void) and operate by side effects, so they're unaffected.
3034
+ **On `options.protocols`:** `getValues` runs synchronously and calls protocol handlers without awaiting them. `options.protocols` is typed to allow an async handler (`(key: string) => any | Promise<any>`), because ` =>`'s `resolve:` path can use one — but ` =&` has no await to catch a Promise coming back from one. If a resolved value under ` =&` turns out to be a thenable, assignFrom throws immediately rather than silently assigning `"[object Promise]"`. Use ` =>` with `resolve:` for handler configs that need an async protocol.
3037
3035
 
3038
3036
  ### Built-in handler: `builtIns.microDataJoin`
3039
3037
 
@@ -3095,7 +3093,7 @@ Produces:
3095
3093
 
3096
3094
  **Optional segments (nested arrays):**
3097
3095
 
3098
- Same all-or-nothing semantics as `builtIns.join` — if any `val` in a nested sub-array is null/undefined, the entire sub-array is dropped:
3096
+ Same all-or-nothing semantics as the [`join` sync op](#sync-op-join) — if any `val` in a nested sub-array is null/undefined, the entire sub-array is dropped:
3099
3097
 
3100
3098
  ```JavaScript
3101
3099
  get: {
@@ -3239,7 +3237,7 @@ assignFrom(element, {
3239
3237
 
3240
3238
  ## Typed Path Authoring with `paths`, `sp`, and `md`
3241
3239
 
3242
- For JSON generated config files generated from TypeScript/`.mts`/`mjs` files during a build or server-side rendering, the `paths` utility provides compile-time autocomplete and type safety for `?.`-prefixed path strings. The `sp` tagged template literal ("split into parts") produces arrays suitable for `builtIns.join`. The `md` tagged template literal produces `{prop, val}` objects suitable for `builtIns.microDataJoin`.
3240
+ For JSON generated config files generated from TypeScript/`.mts`/`mjs` files during a build or server-side rendering, the `paths` utility provides compile-time autocomplete and type safety for `?.`-prefixed path strings. The `sp` tagged template literal ("split into parts") produces arrays suitable for the [`join` sync op](#sync-op-join). The `md` tagged template literal produces `{prop, val}` objects suitable for `builtIns.microDataJoin`.
3243
3241
 
3244
3242
  ```TypeScript
3245
3243
  import { paths, sp } from 'assign-gingerly/DX/paths.js';
@@ -3256,11 +3254,8 @@ const $ = paths<Person>();
3256
3254
  // sp produces: ['?.lastName', ', ', '?.firstName']
3257
3255
  // with full autocomplete on $.lastName, $.firstName, etc.
3258
3256
  export default {
3259
- '?.textContent =>': {
3260
- do: 'builtIns.join',
3261
- get: {
3262
- value: sp`${$.lastName}, ${$.firstName}`
3263
- }
3257
+ '?.textContent =&': {
3258
+ join: sp`${$.lastName}, ${$.firstName}`
3264
3259
  }
3265
3260
  };
3266
3261
  ```
@@ -3268,9 +3263,9 @@ export default {
3268
3263
  **How `paths` works:**
3269
3264
 
3270
3265
  - `paths<T>()` creates a deeply-proxied object typed as `T`
3271
- - Every property access returns a deeper proxy (e.g., `$.address.city`)
3266
+ - Every property access returns a deeper proxy (e.g., `$.address.city`)
3272
3267
  - Capitalized reserved tokens like `.Each` and `.EqNot` normalize into command syntax
3273
- - `.Path` extracts the `?.`-prefixed string: `$.address.city.Path` → `'?.address?.city'`
3268
+ - `.Path` extracts the `?.`-prefixed string: `$.address.city.Path` → `'?.address?.city'`
3274
3269
  - Inside `sp` template literals, `.Path` is not needed — proxy objects are auto-detected
3275
3270
 
3276
3271
  **How `sp` works:**
@@ -3339,7 +3334,7 @@ md`${$.firstName} ${{ prop: 'birthDate', val: $.birthDT, format: 'long' }}`
3339
3334
 
3340
3335
  | Tag | Output for proxy interpolation | Use with |
3341
3336
  |-----|-------------------------------|----------|
3342
- | `sp` | `'?.firstName'` (path string) | `builtIns.join` |
3337
+ | `sp` | `'?.firstName'` (path string) | the `join` sync op |
3343
3338
  | `md` | `{ prop: 'firstName', val: '?.firstName' }` | `builtIns.microDataJoin` |
3344
3339
 
3345
3340
  Both auto-detect path proxies (no `.Path` needed inside template literals) and preserve nested arrays for optional segments.
package/assignFrom.js CHANGED
@@ -7,11 +7,14 @@
7
7
  * For async protocol handlers or awaitable handler execution, use assignFromAsync.
8
8
  *
9
9
  * Handler commands (` =>`) are fire-and-forget (kicked off asynchronously, not awaited).
10
+ * Sync-op commands (` =&`) are always fully synchronous — see SYNC_OPS.
10
11
  */
11
12
  import { getValues, getValue } from './resolve/getValues.js';
12
13
  import assignGingerly from './assignGingerly.js';
13
14
  import { resolveIdVariable, parseIdRef } from './resolve/resolveIdRef.js';
14
15
  import { processInferredAssignments } from './inferredAssignments.js';
16
+ import { resolveLhsPath } from './utils/resolveLhsPath.js';
17
+ import { SYNC_OPS } from './syncOps/registry.js';
15
18
  /**
16
19
  * Supported substitution variables and their option keys.
17
20
  */
@@ -40,6 +43,48 @@ export function parseTernaryCommand(key) {
40
43
  return null;
41
44
  return key.substring(0, key.length - 3); // Remove ' ?=' suffix
42
45
  }
46
+ /**
47
+ * Check if a key ends with the sync-op operator ' =&'.
48
+ */
49
+ export function isSyncOpCommand(key) {
50
+ return key.endsWith(' =&');
51
+ }
52
+ /**
53
+ * Parse a =& sync-op command and extract the LHS path.
54
+ */
55
+ export function parseSyncOpCommand(key) {
56
+ if (!isSyncOpCommand(key))
57
+ return null;
58
+ return key.substring(0, key.length - 3); // Remove ' =&' suffix
59
+ }
60
+ /**
61
+ * Throws if a resolved sync-op value (or anything nested inside it) is a thenable.
62
+ *
63
+ * getValues is synchronous by contract, but `options.protocols` handlers are
64
+ * typed to allow returning a Promise. A sync op has no await to catch that —
65
+ * left unchecked it would silently stringify as "[object Promise]" — so this
66
+ * turns it into a clear error instead.
67
+ */
68
+ function assertNoThenable(value, opName, key) {
69
+ if (value == null)
70
+ return;
71
+ if (typeof value.then === 'function') {
72
+ throw new Error(`assignFrom: sync op '${opName}' (key "${key}") received an async value — ` +
73
+ `a protocol in options.protocols returned a Promise, which ' =&' cannot await. ` +
74
+ `Use ' =>' with 'resolve:' instead for async values.`);
75
+ }
76
+ if (Array.isArray(value)) {
77
+ for (const item of value)
78
+ assertNoThenable(item, opName, key);
79
+ }
80
+ else if (typeof value === 'object') {
81
+ const proto = Object.getPrototypeOf(value);
82
+ if (proto === Object.prototype || proto === null) {
83
+ for (const item of Object.values(value))
84
+ assertNoThenable(item, opName, key);
85
+ }
86
+ }
87
+ }
43
88
  /**
44
89
  * Resolve a single value — if it's a `?.` path string, resolve against source.
45
90
  * If it's a protocol string, resolve via protocol. Otherwise pass through as literal.
@@ -343,6 +388,7 @@ export function categorizeKeys(expandedPattern) {
343
388
  const idRefNormalKeys = [];
344
389
  const idRefHandlerKeys = [];
345
390
  const ternaryKeys = [];
391
+ const syncOpKeys = [];
346
392
  for (const key of Object.keys(expandedPattern)) {
347
393
  if (isHandlerCommand(key)) {
348
394
  if (key.startsWith('#[')) {
@@ -355,6 +401,9 @@ export function categorizeKeys(expandedPattern) {
355
401
  else if (isTernaryCommand(key)) {
356
402
  ternaryKeys.push(key);
357
403
  }
404
+ else if (isSyncOpCommand(key)) {
405
+ syncOpKeys.push(key);
406
+ }
358
407
  else if (key.startsWith('#[')) {
359
408
  idRefNormalKeys.push(key);
360
409
  }
@@ -362,7 +411,7 @@ export function categorizeKeys(expandedPattern) {
362
411
  normalPattern[key] = expandedPattern[key];
363
412
  }
364
413
  }
365
- return { handlerKeys, normalPattern, idRefNormalKeys, idRefHandlerKeys, ternaryKeys };
414
+ return { handlerKeys, normalPattern, idRefNormalKeys, idRefHandlerKeys, ternaryKeys, syncOpKeys };
366
415
  }
367
416
  /**
368
417
  * Merge pin and at into a single lookup map for resolveIdVariable.
@@ -420,7 +469,7 @@ export function assignFrom(target, pattern, options, permissionProcessor) {
420
469
  // Expand looped substitution variables
421
470
  const expandedPattern = expandSubstitutions(pattern, options);
422
471
  // Categorize keys
423
- const { handlerKeys, normalPattern, idRefNormalKeys, idRefHandlerKeys, ternaryKeys } = categorizeKeys(expandedPattern);
472
+ const { handlerKeys, normalPattern, idRefNormalKeys, idRefHandlerKeys, ternaryKeys, syncOpKeys } = categorizeKeys(expandedPattern);
424
473
  const resolveOptions = { ...options, root: target, permissionProcessor };
425
474
  // Process ?= ternary keys (sync)
426
475
  if (ternaryKeys.length > 0) {
@@ -441,6 +490,33 @@ export function assignFrom(target, pattern, options, permissionProcessor) {
441
490
  assignGingerly(target, ternaryResolved, options, permissionProcessor);
442
491
  }
443
492
  }
493
+ // Process =& sync-op keys (sync — always, no dynamic import, no await, ever)
494
+ if (syncOpKeys.length > 0) {
495
+ for (const key of syncOpKeys) {
496
+ const lhsPath = parseSyncOpCommand(key);
497
+ if (lhsPath === null)
498
+ continue;
499
+ const config = expandedPattern[key];
500
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
501
+ throw new Error(`assignFrom: sync-op command "${key}" requires a config object naming exactly one op`);
502
+ }
503
+ const opName = Object.keys(config).find(k => k in SYNC_OPS);
504
+ if (!opName) {
505
+ throw new Error(`assignFrom: sync-op command "${key}" does not name a known op (${Object.keys(SYNC_OPS).join(', ')})`);
506
+ }
507
+ const resolvedConfig = getValues(config, options.from, resolveOptions);
508
+ assertNoThenable(resolvedConfig, opName, key);
509
+ const { [opName]: args, ...extra } = resolvedConfig;
510
+ const result = SYNC_OPS[opName](args, extra);
511
+ if (result === undefined)
512
+ continue;
513
+ const { lhsParent, lhsKey } = resolveLhsPath(target, lhsPath, options);
514
+ if (lhsParent != null && lhsKey != null
515
+ && !permissionProcessor?.redirectRestrictedProp(lhsParent, lhsKey, result)) {
516
+ lhsParent[lhsKey] = result;
517
+ }
518
+ }
519
+ }
444
520
  // Process normal keys via getValues (sync) + assignGingerly
445
521
  if (Object.keys(normalPattern).length > 0) {
446
522
  // Resolve #[x] references on RHS values before getValues
package/assignFrom.ts CHANGED
@@ -7,12 +7,15 @@
7
7
  * For async protocol handlers or awaitable handler execution, use assignFromAsync.
8
8
  *
9
9
  * Handler commands (` =>`) are fire-and-forget (kicked off asynchronously, not awaited).
10
+ * Sync-op commands (` =&`) are always fully synchronous — see SYNC_OPS.
10
11
  */
11
12
 
12
13
  import { getValues, getValue } from './resolve/getValues.js';
13
14
  import assignGingerly from './assignGingerly.js';
14
15
  import { resolveIdVariable, parseIdRef } from './resolve/resolveIdRef.js';
15
16
  import { processInferredAssignments } from './inferredAssignments.js';
17
+ import { resolveLhsPath } from './utils/resolveLhsPath.js';
18
+ import { SYNC_OPS } from './syncOps/registry.js';
16
19
  import type { PermissionProcessor, AssignFromOptions, AssignFromHandler, AssignFromHandlerConstructor } from './types/assign-gingerly/types.js';
17
20
 
18
21
  // Re-export types for consumers
@@ -49,6 +52,48 @@ export function parseTernaryCommand(key: string): string | null {
49
52
  return key.substring(0, key.length - 3); // Remove ' ?=' suffix
50
53
  }
51
54
 
55
+ /**
56
+ * Check if a key ends with the sync-op operator ' =&'.
57
+ */
58
+ export function isSyncOpCommand(key: string): boolean {
59
+ return key.endsWith(' =&');
60
+ }
61
+
62
+ /**
63
+ * Parse a =& sync-op command and extract the LHS path.
64
+ */
65
+ export function parseSyncOpCommand(key: string): string | null {
66
+ if (!isSyncOpCommand(key)) return null;
67
+ return key.substring(0, key.length - 3); // Remove ' =&' suffix
68
+ }
69
+
70
+ /**
71
+ * Throws if a resolved sync-op value (or anything nested inside it) is a thenable.
72
+ *
73
+ * getValues is synchronous by contract, but `options.protocols` handlers are
74
+ * typed to allow returning a Promise. A sync op has no await to catch that —
75
+ * left unchecked it would silently stringify as "[object Promise]" — so this
76
+ * turns it into a clear error instead.
77
+ */
78
+ function assertNoThenable(value: any, opName: string, key: string): void {
79
+ if (value == null) return;
80
+ if (typeof value.then === 'function') {
81
+ throw new Error(
82
+ `assignFrom: sync op '${opName}' (key "${key}") received an async value — ` +
83
+ `a protocol in options.protocols returned a Promise, which ' =&' cannot await. ` +
84
+ `Use ' =>' with 'resolve:' instead for async values.`
85
+ );
86
+ }
87
+ if (Array.isArray(value)) {
88
+ for (const item of value) assertNoThenable(item, opName, key);
89
+ } else if (typeof value === 'object') {
90
+ const proto = Object.getPrototypeOf(value);
91
+ if (proto === Object.prototype || proto === null) {
92
+ for (const item of Object.values(value)) assertNoThenable(item, opName, key);
93
+ }
94
+ }
95
+ }
96
+
52
97
  /**
53
98
  * Resolve a single value — if it's a `?.` path string, resolve against source.
54
99
  * If it's a protocol string, resolve via protocol. Otherwise pass through as literal.
@@ -354,6 +399,7 @@ export function categorizeKeys(expandedPattern: Record<string, any>) {
354
399
  const idRefNormalKeys: string[] = [];
355
400
  const idRefHandlerKeys: string[] = [];
356
401
  const ternaryKeys: string[] = [];
402
+ const syncOpKeys: string[] = [];
357
403
 
358
404
  for (const key of Object.keys(expandedPattern)) {
359
405
  if (isHandlerCommand(key)) {
@@ -364,6 +410,8 @@ export function categorizeKeys(expandedPattern: Record<string, any>) {
364
410
  }
365
411
  } else if (isTernaryCommand(key)) {
366
412
  ternaryKeys.push(key);
413
+ } else if (isSyncOpCommand(key)) {
414
+ syncOpKeys.push(key);
367
415
  } else if (key.startsWith('#[')) {
368
416
  idRefNormalKeys.push(key);
369
417
  } else {
@@ -371,7 +419,7 @@ export function categorizeKeys(expandedPattern: Record<string, any>) {
371
419
  }
372
420
  }
373
421
 
374
- return { handlerKeys, normalPattern, idRefNormalKeys, idRefHandlerKeys, ternaryKeys };
422
+ return { handlerKeys, normalPattern, idRefNormalKeys, idRefHandlerKeys, ternaryKeys, syncOpKeys };
375
423
  }
376
424
 
377
425
  /**
@@ -447,7 +495,7 @@ export function assignFrom(
447
495
  const expandedPattern = expandSubstitutions(pattern, options);
448
496
 
449
497
  // Categorize keys
450
- const { handlerKeys, normalPattern, idRefNormalKeys, idRefHandlerKeys, ternaryKeys } = categorizeKeys(expandedPattern);
498
+ const { handlerKeys, normalPattern, idRefNormalKeys, idRefHandlerKeys, ternaryKeys, syncOpKeys } = categorizeKeys(expandedPattern);
451
499
 
452
500
  const resolveOptions = { ...options, root: target, permissionProcessor } as any;
453
501
 
@@ -469,6 +517,36 @@ export function assignFrom(
469
517
  }
470
518
  }
471
519
 
520
+ // Process =& sync-op keys (sync — always, no dynamic import, no await, ever)
521
+ if (syncOpKeys.length > 0) {
522
+ for (const key of syncOpKeys) {
523
+ const lhsPath = parseSyncOpCommand(key);
524
+ if (lhsPath === null) continue;
525
+ const config = expandedPattern[key];
526
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
527
+ throw new Error(`assignFrom: sync-op command "${key}" requires a config object naming exactly one op`);
528
+ }
529
+
530
+ const opName = Object.keys(config).find(k => k in SYNC_OPS);
531
+ if (!opName) {
532
+ throw new Error(`assignFrom: sync-op command "${key}" does not name a known op (${Object.keys(SYNC_OPS).join(', ')})`);
533
+ }
534
+
535
+ const resolvedConfig = getValues(config, options.from, resolveOptions);
536
+ assertNoThenable(resolvedConfig, opName, key);
537
+
538
+ const { [opName]: args, ...extra } = resolvedConfig;
539
+ const result = SYNC_OPS[opName](args, extra);
540
+ if (result === undefined) continue;
541
+
542
+ const { lhsParent, lhsKey } = resolveLhsPath(target, lhsPath, options);
543
+ if (lhsParent != null && lhsKey != null
544
+ && !permissionProcessor?.redirectRestrictedProp(lhsParent, lhsKey, result)) {
545
+ lhsParent[lhsKey] = result;
546
+ }
547
+ }
548
+ }
549
+
472
550
  // Process normal keys via getValues (sync) + assignGingerly
473
551
  if (Object.keys(normalPattern).length > 0) {
474
552
  // Resolve #[x] references on RHS values before getValues