@supersoniks/concorde 5.1.3-immutable.1 → 5.1.3-immutable.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersoniks/concorde",
3
- "version": "5.1.3-immutable.1",
3
+ "version": "5.1.3-immutable.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": 1,
3
- "generatedAt": "2026-10-29T21:35:24.462Z",
3
+ "generatedAt": "2026-11-02T09:56:04.637Z",
4
4
  "count": 66,
5
5
  "items": [
6
6
  {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": 1,
3
- "generatedAt": "2026-10-29T21:35:24.462Z",
3
+ "generatedAt": "2026-11-02T09:56:04.637Z",
4
4
  "componentCount": 67,
5
5
  "components": [
6
6
  {
@@ -481,16 +481,16 @@ export class PublisherProxy<T = any> {
481
481
  const currentAtKey = (parentRaw as any)[this._parentKey_];
482
482
  if (currentAtKey === valueTosetInParent) return;
483
483
 
484
- const nextParent = setAtKey(
484
+ // Silent rebuild (like the pre-immutable `parent._value_[key] = v`): ancestors get
485
+ // onInternalMutation via bubbling, never onAssign — else root onAssign listeners that
486
+ // write a child loop forever (seatmap Haxe `publisher.onAssign` → `mainState = …`).
487
+ this.parent._value_ = setAtKey(
485
488
  parentRaw as object,
486
489
  this._parentKey_,
487
490
  valueTosetInParent,
488
- );
489
- this.parent.set(
490
- nextParent as any,
491
- lockInternalMutationsTransmission,
492
- true,
493
- );
491
+ ) as any;
492
+ this.parent._cachedGet_ = undefined;
493
+ this.parent._syncOwnedValueIntoParent_(lockInternalMutationsTransmission);
494
494
  }
495
495
  /**
496
496
  * Extraire la valeur actuelle du proxy
@@ -1,4 +1,4 @@
1
- import { describe, expect, it, vi } from "vitest";
1
+ import { describe, expect, it } from "vitest";
2
2
  import {
3
3
  deepFreeze,
4
4
  deleteAtKey,
@@ -41,14 +41,18 @@ describe("immutableValue helpers", () => {
41
41
  expect(next.a).toBe(1);
42
42
  });
43
43
 
44
- it("unclonable values warn and pass through", () => {
45
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
44
+ it("unclonable leaves: plain containers copied + frozen, leaves kept by reference", () => {
46
45
  const fn = () => 1;
47
- const obj = { fn };
48
- // functions inside plain objects: structuredClone throws in many engines
46
+ const event = new Event("x");
47
+ const obj = { fn, nested: { event, list: [1] } };
48
+ // functions / Event: structuredClone throws
49
49
  const owned = takeOwnership(obj);
50
- expect(owned).toBe(obj);
51
- expect(warn).toHaveBeenCalled();
52
- warn.mockRestore();
50
+ expect(owned).not.toBe(obj);
51
+ expect(Object.isFrozen(obj)).toBe(false);
52
+ expect(Object.isFrozen(obj.nested)).toBe(false);
53
+ expect(Object.isFrozen(owned.nested.list)).toBe(true);
54
+ expect(owned.fn).toBe(fn);
55
+ expect(owned.nested.event).toBe(event);
56
+ expect(Object.isFrozen(event)).toBe(false);
53
57
  });
54
58
  });
@@ -3,15 +3,24 @@
3
3
  * External values are cloned then deep-frozen; internal updates rebuild via spread.
4
4
  */
5
5
 
6
- const warnedUnclonable = new WeakSet<object>();
7
-
8
6
  export function isFrozenValue(value: unknown): boolean {
9
7
  return value != null && typeof value === "object" && Object.isFrozen(value);
10
8
  }
11
9
 
12
- /** Deep freeze (in place). Safe to call on already-frozen graphs. */
10
+ /** Plain object / array — the only shapes the store clones and freezes. */
11
+ function isPlainData(value: unknown): value is object {
12
+ if (value == null || typeof value !== "object") return false;
13
+ if (Array.isArray(value)) return true;
14
+ const proto = Object.getPrototypeOf(value);
15
+ return proto === Object.prototype || proto === null;
16
+ }
17
+
18
+ /**
19
+ * Deep freeze plain objects / arrays (in place). Safe on already-frozen graphs.
20
+ * Non-plain values (Response, Event, DOM, class instances) are left untouched.
21
+ */
13
22
  export function deepFreeze<T>(value: T): T {
14
- if (value == null || typeof value !== "object" || Object.isFrozen(value)) {
23
+ if (!isPlainData(value) || Object.isFrozen(value)) {
15
24
  return value;
16
25
  }
17
26
  Object.freeze(value);
@@ -27,7 +36,8 @@ export function deepFreeze<T>(value: T): T {
27
36
 
28
37
  /**
29
38
  * Clone so the publisher owns the data, then freeze.
30
- * Non-cloneable values (EventSource, etc.): kept as-is, not frozen (warned once).
39
+ * Graph with non-cloneable leaves (functions, Response, Event…): plain containers are
40
+ * copied + frozen, those leaves kept by reference — the caller's objects are never frozen.
31
41
  */
32
42
  export function takeOwnership<T>(value: T): T {
33
43
  if (value == null || typeof value !== "object") return value;
@@ -35,16 +45,20 @@ export function takeOwnership<T>(value: T): T {
35
45
  try {
36
46
  return deepFreeze(structuredClone(value));
37
47
  } catch {
38
- if (!warnedUnclonable.has(value as object)) {
39
- warnedUnclonable.add(value as object);
40
- console.warn(
41
- "[Concorde] takeOwnership: value is not structuredClone-able; stored without freeze.",
42
- );
43
- }
44
- return value;
48
+ return ownPlainData(value);
45
49
  }
46
50
  }
47
51
 
52
+ function ownPlainData<T>(value: T): T {
53
+ if (!isPlainData(value) || Object.isFrozen(value)) return value;
54
+ const copy = Array.isArray(value)
55
+ ? value.map(ownPlainData)
56
+ : Object.fromEntries(
57
+ Object.entries(value).map(([key, v]) => [key, ownPlainData(v)]),
58
+ );
59
+ return Object.freeze(copy) as T;
60
+ }
61
+
48
62
  /** Drop `undefined` own keys on plain objects (before freeze). Arrays unchanged. */
49
63
  export function stripUndefinedKeys<T>(value: T): T {
50
64
  if (value == null || typeof value !== "object" || Array.isArray(value)) {
@@ -55,6 +55,57 @@ describe("immutable DataProvider store (79567)", () => {
55
55
  expect(get(cartKey)).toBe(snap);
56
56
  });
57
57
 
58
+ it("non-cloneable leaf: store owns plain data, caller objects stay unfrozen", () => {
59
+ const key = "publisherImmutableUnclonable";
60
+ class HttpLike {
61
+ ok = true;
62
+ }
63
+ const http = new HttpLike();
64
+ const processed = { messages: [{ type: "error" }] };
65
+ const onProcessed = vi.fn();
66
+ dp(key).processed.onAssign(onProcessed, false);
67
+
68
+ dp(key).set({ http, processed, onDone: () => 1 });
69
+
70
+ expect(Object.isFrozen(processed)).toBe(false);
71
+ expect(Object.isFrozen(processed.messages)).toBe(false);
72
+ expect(Object.isFrozen(http)).toBe(false);
73
+ const stored = dp(key).get() as any;
74
+ expect(stored.http).toBe(http);
75
+ expect(stored.processed).toEqual(processed);
76
+ expect(Object.isFrozen(stored.processed.messages)).toBe(true);
77
+ expect(onProcessed).toHaveBeenCalledWith(stored.processed);
78
+ PublisherManager.getInstance().delete(key);
79
+ });
80
+
81
+ it("nested set notifies parent internal mutation, not parent onAssign", () => {
82
+ set(cartKey, { items: [{ id: 1 }], total: 0 });
83
+ const parentAssign = vi.fn();
84
+ const parentMutation = vi.fn();
85
+ dp(cartKey).onAssign(parentAssign, false);
86
+ dp(cartKey).onInternalMutation(parentMutation);
87
+ parentAssign.mockClear();
88
+ parentMutation.mockClear();
89
+
90
+ dp(cartKey.total).set(10);
91
+
92
+ expect(parentAssign).not.toHaveBeenCalled();
93
+ expect(parentMutation).toHaveBeenCalled();
94
+ expect(get(cartKey).total).toBe(10);
95
+ expect(Object.isFrozen(get(cartKey))).toBe(true);
96
+ });
97
+
98
+ it("root onAssign writing a child does not loop", () => {
99
+ set(cartKey, { items: [], total: 0 });
100
+ let calls = 0;
101
+ dp(cartKey).onAssign(() => {
102
+ calls++;
103
+ dp(cartKey.total).set(calls);
104
+ }, false);
105
+ set(cartKey, { items: [{ id: 1 }], total: 0 });
106
+ expect(calls).toBe(1);
107
+ });
108
+
58
109
  it("re-assigning the same nested value does not re-publish the parent", () => {
59
110
  const listKey = new DataProviderKey<Row[]>("publisherImmutableListLoop");
60
111
  set(listKey, [{ id: 1 }]);
@@ -12,6 +12,16 @@ On a [starter](/docs/getting-started/installation) project, run `yarn ai:sync` a
12
12
  | Any consumer app (after `yarn add @supersoniks/concorde`) | `node node_modules/@supersoniks/concorde/scripts/ai-init.mjs` |
13
13
  | Concorde repo (contributors) | `yarn ai-init` |
14
14
 
15
+ <sonic-code language="shell">
16
+ <template>
17
+ # Starter (from project root)
18
+ yarn ai:sync
19
+
20
+ # Existing project
21
+ node node_modules/@supersoniks/concorde/scripts/ai-init.mjs
22
+ </template>
23
+ </sonic-code>
24
+
15
25
  Re-run the same command after each Concorde upgrade to refresh the skill.
16
26
 
17
27
  ## The skill
@@ -28,6 +38,20 @@ Re-run the same command after each Concorde upgrade to refresh the skill.
28
38
  | `rules/composition.md` | Parts of compound components, built-in states |
29
39
  | `rules/troubleshooting.md` | Symptom → cause → fix: values not updating, requests not sent, empty lists |
30
40
 
41
+ ### Sources of truth
42
+
43
+ The skill tells the agent to check, in this order:
44
+
45
+ 1. **The project** — the installed Concorde version (`npm ls @supersoniks/concorde`) and its existing `DataProviderKey`s, `Endpoint`s and components.
46
+ 2. **These docs** — index at [llms.txt](https://concorde.supersoniks.org/llms.txt); every page is served as Markdown when `.md` is appended to its URL. Offline: `node_modules/@supersoniks/concorde/src/**/*.md`.
47
+ 3. **The element API** — attributes, properties, events and slots in `node_modules/@supersoniks/concorde/custom-elements.json`. The file is large, so the agent queries one element:
48
+
49
+ <sonic-code language="shell">
50
+ <template>
51
+ node -e 'const t=process.argv[1];for(const m of require("@supersoniks/concorde/custom-elements.json").modules)for(const d of m.declarations)if(d.tagName===t)console.log(JSON.stringify({attributes:d.attributes?.map(a=>[a.name,a.type?.text,a.default]),properties:d.members?.filter(p=>p.kind==="field"&&!p.attribute).map(p=>p.name),events:d.events?.map(e=>e.name),slots:d.slots?.map(s=>s.name)},null,1))' sonic-select
52
+ </template>
53
+ </sonic-code>
54
+
31
55
  ## What gets installed
32
56
 
33
57
  | Path | Role |
@@ -36,7 +60,7 @@ Re-run the same command after each Concorde upgrade to refresh the skill.
36
60
  | `.claude/skills/concorde` | Symlink for Claude Code |
37
61
  | `.junie/skills/concorde` | Symlink for JetBrains Junie |
38
62
 
39
- Where symlinks are not allowed (some Windows setups), the skill is copied instead. Your own skills in these folders are never touched. Commit `.agents/` and the links if your team shares agent settings.
63
+ Where symlinks are not allowed (some Windows setups), the skill is copied instead. Only Concorde's own skill is replaced on each run: a project skill with another name is left alone, and one with the same name in `.claude/skills` or `.junie/skills` is never overwritten. If `.claude/skills` already points to `.agents/skills`, no per-skill link is created. Commit `.agents/` and the links if your team shares agent settings.
40
64
 
41
65
  **`AGENTS.md` stays yours:** `ai-init` never reads or writes it.
42
66
 
@@ -46,5 +70,7 @@ Where symlinks are not allowed (some Windows setups), the skill is copied instea
46
70
  |------|--------|
47
71
  | `--no-links` | Install `.agents/skills` only, without the `.claude` / `.junie` links |
48
72
  | `--overlay <dir>` | Also install the skills found in `<dir>/skills/` (project kit) |
73
+ | `--project-root <dir>` | Project to install into (default: current directory) |
74
+ | `--concorde-ai <dir>` | Concorde `ai/` folder to install from (default: the package's own) |
49
75
 
50
76
  Source files in the npm package: `node_modules/@supersoniks/concorde/ai/skills/concorde/`.