@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/build-infos.json +1 -1
- package/concorde-core.bundle.js +137 -137
- package/concorde-core.es.js +1482 -1477
- package/dist/concorde-core.bundle.js +137 -137
- package/dist/concorde-core.es.js +1482 -1477
- package/package.json +1 -1
- package/src/core/components/functional/sdui/component-catalog.gaps.json +1 -1
- package/src/core/components/functional/sdui/component-catalog.json +1 -1
- package/src/core/utils/PublisherProxy.ts +7 -7
- package/src/core/utils/immutableValue.spec.ts +12 -8
- package/src/core/utils/immutableValue.ts +26 -12
- package/src/core/utils/publisherImmutable.spec.ts +51 -0
- package/src/docs/getting-started/ai-agents.md +27 -1
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
490
|
-
|
|
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
|
|
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
|
|
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
|
|
48
|
-
|
|
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(
|
|
52
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
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.
|
|
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/`.
|