{"_id":"@archduck/gst-compose","name":"@archduck/gst-compose","dist-tags":{"latest":"0.1.0"},"versions":{"0.1.0":{"name":"@archduck/gst-compose","version":"0.1.0","type":"module","description":"Generic JSON/JS composition engine with spread semantics","keywords":["composition","configuration","json","spread","merge"],"author":{"name":"Alan Aydelott"},"license":"MIT","repository":{"type":"git","url":"git+https://gitlab.com/Aydelott/gst-compose.git"},"homepage":"https://gitlab.com/Aydelott/gst-compose","bugs":{"url":"https://gitlab.com/Aydelott/gst-compose/-/issues"},"exports":{".":"./src/index.js"},"main":"src/index.js","dependencies":{},"_id":"@archduck/gst-compose@0.1.0","_nodeVersion":"22.22.2","_npmVersion":"11.14.1","dist":{"integrity":"sha512-YY/qivxaDLdRbPNK5k1FhDYv/M7BNP4FIeDHEkBwfnfoikWgXArSHAAvQpIUt7gKWCaizelJLiQ/py/eETYNCw==","shasum":"932fe3f100132782fbc3fda1f889b521e0d1985e","tarball":"https://registry.npmjs.org/@archduck/gst-compose/-/gst-compose-0.1.0.tgz","fileCount":6,"unpackedSize":22873,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIQDF89rk/hS+067ylVCNcwoCwlBxvlc9gmpMzDh/yrw9jAIgWBJ19wV1rAFRbR33NH4ZiNTfS0Gws3wIQe12T1sosDY="}]},"_npmUser":{"name":"archduck","email":"alan.aydelott@gmail.com"},"directories":{},"maintainers":[{"name":"archduck","email":"alan.aydelott@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/gst-compose_0.1.0_1780100480592_0.18948097957619892"},"_hasShrinkwrap":false}},"time":{"created":"2026-05-30T00:21:20.456Z","0.1.0":"2026-05-30T00:21:20.739Z","modified":"2026-05-30T00:21:20.966Z"},"maintainers":[{"name":"archduck","email":"alan.aydelott@gmail.com"}],"description":"Generic JSON/JS composition engine with spread semantics","homepage":"https://gitlab.com/Aydelott/gst-compose","keywords":["composition","configuration","json","spread","merge"],"repository":{"type":"git","url":"git+https://gitlab.com/Aydelott/gst-compose.git"},"author":{"name":"Alan Aydelott"},"bugs":{"url":"https://gitlab.com/Aydelott/gst-compose/-/issues"},"license":"MIT","readme":"# gst-compose\n\nA JavaScript library for composing JSON data. Apply a patch of new data to existing data to replace it or merge with it. Pass in a dictionary for the patch to reference things by name.\n\n**gst** stands for **Grand Schema Things** -- a play on \"grand scheme of things.\" The gst family of libraries builds UIs from JSON configurations rather than code. gst-compose is the foundation: the composition engine that makes layered config possible.\n\nTwo functions, zero dependencies, ~230 lines.\n\n```\nnpm install @archduck/gst-compose\n```\n\n```js\nimport { apply, reduce } from '@archduck/gst-compose'\n```\n\n---\n\n## Why\n\nConfig varies. A SaaS product has per-tenant settings. An app has dev/staging/prod environments. A UI has locale-specific strings. The usual options are branching logic (`if tenant === 'acme'`) or deep merge (`lodash.merge`).\n\nBranching grows with every new variant. Deep merge gives you no control -- it merges everything recursively, which isn't always what you want. Sometimes you need to replace an array, sometimes extend it. Sometimes replace a nested object, sometimes patch one field.\n\ngst-compose puts the merge strategy in the data itself. A patch that uses `\"...\": \"^\"` keeps the base and overrides on top. A patch without it replaces entirely. This decision is made per-key, at any nesting depth, by the patch -- not by the code that processes it. The processing code is always just `apply(base, patch)`.\n\nbase.json -- the default compensation package:\n\n```json\n{\n  \"salary\": 50000,\n  \"bonus\": false,\n  \"retirement\": \"none\",\n  \"insurance\": \"basic\",\n  \"dental\": true,\n  \"vision\": true,\n  \"pto\": 10,\n  \"sickDays\": 5,\n  \"remote\": false,\n  \"relocation\": false,\n  \"parking\": true,\n  \"laptop\": \"standard\",\n  \"phone\": false,\n  \"tuition\": false,\n  \"gym\": false\n}\n```\n\nsenior.json -- two changes:\n\n```json\n{ \"...\": \"^\", \"salary\": 90000, \"bonus\": true }\n```\n\nexecutive.json -- three changes:\n\n```json\n{ \"...\": \"^\", \"salary\": 150000, \"retirement\": \"401k-match\", \"insurance\": \"premium\" }\n```\n\nThe JavaScript:\n\n```js\nconst config = apply(base, patch)\n```\n\nOne line. The patch file describes everything -- what to keep, what to change. You can see at a glance what each level overrides.\n\n---\n\n## Spread for JSON\n\nJavaScript merges objects with spread:\n\n```js\nconst defaultPrefs = { lang: \"en\", notifications: true, theme: \"light\" }\nconst userPrefs = { ...defaultPrefs, theme: \"dark\" }\n// { lang: \"en\", notifications: true, theme: \"dark\" }\n```\n\nJSON has no spread operator. gst-compose adds one. The `\"...\"` key means \"spread from\":\n\n```json\n{ \"...\": \"defaultPrefs\", \"theme\": \"dark\" }\n```\n\nWhen gst-compose processes this, it finds the object called `\"defaultPrefs\"`, spreads its properties in, then applies `\"theme\": \"dark\"` on top. Same result as the JS spread.\n\n---\n\n## apply\n\n`apply(base, patch, dictionary)` -- combine two objects into one.\n\n- `base` -- the existing object\n- `patch` -- the new object, applied on top of `base`\n- `dictionary` -- a dictionary of objects that `patch` can reference by key\n\n`patch` replaces `base`:\n\n```js\napply(\n  {\n    salary: 50000,\n    bonus: false,\n    retirement: \"none\",\n    insurance: \"basic\",\n    pto: 10\n  },\n  { salary: 90000 }\n)\n// !! { salary: 90000 }\n```\n\nOnly `patch` survives. To keep the rest of the record, `patch` spreads from `base` with `\"...\": \"^\"`:\n\n```js\napply(\n  {\n    salary: 50000,\n    bonus: false,\n    retirement: \"none\",\n    insurance: \"basic\",\n    pto: 10\n  },\n  { \"...\": \"^\", salary: 90000 }\n)\n// {\n//   salary: 90000,\n//   bonus: false,\n//   retirement: \"none\",\n//   insurance: \"basic\",\n//   pto: 10\n// }\n```\n\n`patch` can also spread from a key in `dictionary`:\n\n```js\napply(\n  {\n    salary: 50000,\n    bonus: false,\n    retirement: \"none\",\n    insurance: \"basic\",\n    pto: 10\n  },\n  { \"...\": \"senior\", pto: 25 },\n  {\n    senior: {\n      salary: 90000,\n      bonus: true,\n      pto: 20\n    }\n  }\n)\n// { salary: 90000, bonus: true, pto: 25 }\n```\n\nTo spread from both `base` and a dictionary entry, use an array:\n\n```js\napply(\n  {\n    salary: 50000,\n    bonus: false,\n    retirement: \"none\",\n    insurance: \"basic\",\n    pto: 10\n  },\n  { \"...\": [\"^\", \"senior\"], pto: 25 },\n  {\n    senior: {\n      salary: 90000,\n      bonus: true,\n      pto: 20\n    }\n  }\n)\n// {\n//   salary: 90000,\n//   bonus: true,\n//   retirement: \"none\",\n//   insurance: \"basic\",\n//   pto: 25\n// }\n```\n\nSpreads `base` first, then `senior` on top, then `patch`'s own keys override both. `retirement` and `insurance` survived from `base` because `senior` doesn't touch them. `pto` is 25 because the patch overrode `senior`'s 20.\n\n---\n\n## Nesting\n\nSpread corresponds to the same location in the base. A nested `\"^\"` spreads from the base's value at that same key:\n\n```js\napply(\n  {\n    salary: 50000,\n    benefits: {\n      insurance: \"basic\",\n      retirement: \"none\"\n    },\n    perks: [\"parking\"]\n  },\n  {\n    \"...\": \"^\",\n    salary: 90000,\n    benefits: {\n      \"...\": \"^\",\n      retirement: \"401k-match\"\n    },\n    perks: [\"...\", \"gym\", \"lunch\"]\n  }\n)\n// {\n//   salary: 90000,\n//   benefits: {\n//     insurance: \"basic\",\n//     retirement: \"401k-match\"\n//   },\n//   perks: [\"parking\", \"gym\", \"lunch\"]\n// }\n```\n\nThe outer `\"^\"` keeps `salary`, `benefits`, and `perks` from the base. The inner `\"^\"` on `benefits` keeps `insurance` while overriding `retirement`. The `\"...\"` in `perks` splices in the base's `[\"parking\"]` and appends the new items.\n\nWithout `\"...\"` at each level, that level gets replaced entirely. Spread is explicit, not recursive.\n\nIf a nested spread doesn't correspond to anything in the base, it fails safely -- `\"^\"` spreads from an empty object, `\"...\"` splices in an empty array:\n\n```js\napply(\n  {\n    salary: 50000\n  },\n  {\n    \"...\": \"^\",\n    benefits: {\n      \"...\": \"^\",\n      retirement: \"401k-match\"\n    }\n  }\n)\n// {\n//   salary: 50000,\n//   benefits: {\n//     retirement: \"401k-match\"\n//   }\n// }\n```\n\nArrays don't support dictionary lookups. `\"...\"` in an array is always a positional marker meaning \"insert the base array here.\"\n\n---\n\n## reduce\n\nApply multiple patches in sequence. Each patch decides independently whether to spread from the previous result or replace it:\n\n```js\nreduce([\n  {\n    salary: 50000,\n    bonus: false,\n    pto: 10\n  },\n  { \"...\": \"^\", salary: 90000 },\n  { \"...\": \"^\", bonus: true },\n  { \"...\": \"^\", pto: 25 }\n])\n// {\n//   salary: 90000,\n//   bonus: true,\n//   pto: 25\n// }\n```\n\nEach entry is applied on top of the accumulated result from left to right.\n\n---\n\n## __source\n\nBring a subset of the dictionary into local scope so children don't need full paths:\n\n```js\nconst dictionary = {\n  packages: {\n    senior: {\n      salary: 90000,\n      bonus: true\n    },\n    executive: {\n      salary: 150000,\n      bonus: true,\n      retirement: \"401k-match\"\n    }\n  }\n}\n\napply(\n  {},\n  {\n    __source: \"packages\",\n    cto: { \"...\": \"executive\", pto: 30 }\n  },\n  dictionary\n)\n// {\n//   cto: {\n//     salary: 150000,\n//     bonus: true,\n//     retirement: \"401k-match\",\n//     pto: 30\n//   }\n// }\n```\n\nWithout `__source`, the child would need `\"...\": \"packages.executive\"`. Dot notation reaches into nested dictionary paths.\n\nAccepts a string, an array of strings, or `null` to block all resolution.\n\n---\n\n## String shorthand\n\nWhen a key and value are the same string, and that string exists in the dictionary, it resolves as a reference:\n\n```js\nconst dictionary = {\n  senior: {\n    salary: 90000,\n    bonus: true\n  },\n  executive: {\n    salary: 150000,\n    bonus: true,\n    retirement: \"401k-match\"\n  }\n}\n\napply(\n  {},\n  {\n    __source: \"roles\",\n    senior: \"senior\",\n    executive: \"executive\"\n  },\n  { roles: dictionary }\n)\n// {\n//   senior: { salary: 90000, bonus: true },\n//   executive: { salary: 150000, bonus: true, retirement: \"401k-match\" }\n// }\n```\n\nEquivalent to `\"senior\": { \"...\": \"senior\" }`.\n\n---\n\n## Idempotency\n\n`apply` consumes its directives (`\"...\"`, `__source`) during resolution. The output is plain data with no directives left. Running it through `apply` again produces the same result:\n\n```js\nconst first = apply(\n  {},\n  { \"...\": \"senior\", pto: 25 },\n  dictionary\n)\nconst second = apply({}, first, dictionary)\n// first and second are structurally identical\n```\n\nFunction values pass through by reference, not by copy.\n\n---\n\n## API\n\n### `apply(base, patch, dictionary?) -> result`\n\nApply patch on top of base, resolving spread and source directives against dictionary.\n\n| Parameter | Type | Default | Description |\n|-----------|------|---------|-------------|\n| base | any | -- | The existing object |\n| patch | any | -- | The new object to apply on top |\n| dictionary | object | `{}` | Objects that patch can reference by key |\n\n### `reduce(patches, initial?, dictionary?) -> result`\n\nApply multiple patches in sequence, left to right.\n\n| Parameter | Type | Default | Description |\n|-----------|------|---------|-------------|\n| patches | array | -- | Patches to apply in order |\n| initial | any | `{}` | Starting value |\n| dictionary | object | `{}` | Objects that patches can reference by key |\n\n### `loadFile(path) -> Promise`\n\nLoad a JSON or JS file. JSON files are fetched and parsed; JS files are dynamically imported.\n\n### Special keys\n\n| Key | In objects | In arrays |\n|-----|-----------|-----------|\n| `\"...\"` | Spread from base (`\"^\"`), dictionary (`\"name\"`), or both (`[\"^\", \"name\"]`) | Spread previous array at this position |\n| `__source` | Narrow dictionary for child lookups | N/A |\n\nBoth keys are consumed during resolution and never appear in the output.\n\n---\n\n## Caveats\n\n- No prototype pollution guards. If the dictionary comes from untrusted input, sanitize keys.\n- No circular reference detection on objects. Circular dictionary strings are fine (path traversal only), but circular object references will overflow the stack.\n- No max depth limit. Config objects rarely go deep enough for this to matter.\n- Unresolved spread references are preserved in the output for multi-pass resolution.\n- Functions pass through by reference, not cloned.\n","readmeFilename":"README.md","_rev":"1-60ef499282183faa1c2caf8f2036b69d"}