Compare commits
11 Commits
45dbd9a33a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f0bae461e | |||
| 70de7afa3c | |||
| 3547967d37 | |||
| 5e91771da6 | |||
| 5cdc6ce77f | |||
| 3046ead08f | |||
| 078d675bbf | |||
| 9103996dbe | |||
| bd48b16e31 | |||
| 7c233df5c0 | |||
| 38b152136b |
@@ -1,266 +0,0 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "ng-eventually",
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"playwright": "^1.61.1",
|
||||
"typescript": "^5.6.0",
|
||||
},
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@ng-eventually/client",
|
||||
"version": "0.0.0",
|
||||
"devDependencies": {
|
||||
"@ng-org/alien-deepsignals": "0.1.2-alpha.11",
|
||||
"@ng-org/shex-orm": "0.1.2-alpha.8",
|
||||
"@ng-org/web": "0.1.2-alpha.13",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ng-org/alien-deepsignals": "*",
|
||||
"@ng-org/orm": "*",
|
||||
"@ng-org/shex-orm": "*",
|
||||
"@ng-org/web": "*",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"@ng-org/alien-deepsignals",
|
||||
"@ng-org/orm",
|
||||
"@ng-org/shex-orm",
|
||||
"@ng-org/web",
|
||||
],
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@ldo/traverser-shexj": ["@ldo/traverser-shexj@1.0.0-alpha.28", "", { "dependencies": { "@ldo/type-traverser": "^1.0.0-alpha.28" } }, "sha512-N06+LOWhv6//unPRLbFMd56MqPf5lO2ihZgle9hNLmxt6QJmNrZM3oXzHCL3TfDu4OT1/NUZp3kj2HmztQIZkg=="],
|
||||
|
||||
"@ldo/type-traverser": ["@ldo/type-traverser@1.0.0-alpha.28", "", { "dependencies": { "uuid": "^8.3.2" } }, "sha512-pGMIVxLzoLjYVhADuVhg6r5ZDNleXZ9DcyIvLXo1/ADEocLnysg/Xjk9D/7l/Rw3WtDJrTFOOtBv8OnH+VPgKA=="],
|
||||
|
||||
"@ng-eventually/client": ["@ng-eventually/client@workspace:packages/client"],
|
||||
|
||||
"@ng-org/alien-deepsignals": ["@ng-org/alien-deepsignals@0.1.2-alpha.11", "", { "dependencies": { "alien-signals": "^2.0.7" }, "peerDependencies": { "react": "^19.0.0 || ^18.0.0", "svelte": "^5.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["react", "svelte", "vue"] }, "sha512-nPgqOrheAda/pW5FHgSb45SrSZWuyMyEVqO683ijEsVPpD105bngfh92PPfcRoRnFzGSoKXa3CfuqUHi2+qVIQ=="],
|
||||
|
||||
"@ng-org/shex-orm": ["@ng-org/shex-orm@0.1.2-alpha.8", "", { "dependencies": { "@ldo/traverser-shexj": "1.0.0-alpha.28", "@ldo/type-traverser": "1.0.0-alpha.28", "@shexjs/parser": "^1.0.0-alpha.24", "commander": "^14.0.1", "dts-dom": "~3.6.0", "ejs": "^3.1.8", "fs-extra": "^10.1.0", "jsonld2graphobject": "^0.0.5", "loading-cli": "^1.1.0", "prettier": "^3.0.3" }, "peerDependencies": { "typescript": "^4.9.0 || ^5.0.0 || ^6.0.0" }, "bin": { "rdf-orm": "dist/cli.js" } }, "sha512-jogxiLfUtI3WkuF5O3PPwRBRWylpapk/JlvElGUqZnEavXXlEMmEGbK3VwqMUsbp1pweNA0sEKIFLIk3Lzt2Gg=="],
|
||||
|
||||
"@ng-org/web": ["@ng-org/web@0.1.2-alpha.13", "", { "dependencies": { "async-proxy": "^0.4.1" } }, "sha512-/xO0c+3NTphnws5Do2LDqgZWmAf+aNnYdChJKdU0dnp1U1iVSgi/y3yb8AYryf0v9sooj0aYJxt08B6DpirFMQ=="],
|
||||
|
||||
"@rdfjs/types": ["@rdfjs/types@1.1.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-wqpOJK1QCbmsGNtyzYnojPU8gRDPid2JO0Q0kMtb4j65xhCK880cnKAfEOwC+dX85VJcCByQx5zOwyyfCjDJsg=="],
|
||||
|
||||
"@shexjs/parser": ["@shexjs/parser@1.0.0-alpha.28", "", { "dependencies": { "@shexjs/util": "^1.0.0-alpha.28", "@ts-jison/parser": "^0.4.1-alpha.1" } }, "sha512-eeVeHq/2JG9X+3h7y+7EmuBSWWl2EMj/EQBLk5CTRx4W4hWDdjWczsY8RWwKjkIzLwUS1+G0aiAI1u5LHCZ2Rw=="],
|
||||
|
||||
"@shexjs/term": ["@shexjs/term@1.0.0-alpha.27", "", { "dependencies": { "@types/shexj": "^2.1.6", "rdf-data-factory": "^1.1.2", "relativize-url": "^0.1.0" } }, "sha512-+D7P7pglRPTZC2RkwaQuq+cgBZImx+61JZtcN77uEJVqcGaIscQK5hScsKhAPIo16/I+4jhIUCEFojXqw6otpg=="],
|
||||
|
||||
"@shexjs/util": ["@shexjs/util@1.0.0-alpha.28", "", { "dependencies": { "@shexjs/term": "^1.0.0-alpha.27", "@shexjs/visitor": "^1.0.0-alpha.27", "@types/shexj": "^2.1.6", "hierarchy-closure": "^1.2.2", "sync-request": "^6.1.0" } }, "sha512-L8pBokTU/5eNRJPkC8R9SIgPw6/JDh/bHKdV5TZzf8/FkOMNJwKIy6UDHXM1I8FJ+c8u2gOOHp2MA+7b+md+0A=="],
|
||||
|
||||
"@shexjs/visitor": ["@shexjs/visitor@1.0.0-alpha.27", "", {}, "sha512-9s67A+f0ZZNw/SNxqoi1483CqUca8dbnHM6WDWsRH4+eXlQpQqwOZDxA8uKEaWeX4VcDrDwzWpr0WvK6EyDWIQ=="],
|
||||
|
||||
"@ts-jison/common": ["@ts-jison/common@0.4.1-alpha.1", "", {}, "sha512-SDbHzq+UMD+V3ciKVBHwCEgVqSeyQPTCjOsd/ZNTGySUVg4x3EauR9ZcEfdVFAsYRR38XWgDI+spq5LDY46KvQ=="],
|
||||
|
||||
"@ts-jison/lexer": ["@ts-jison/lexer@0.4.1-alpha.1", "", { "dependencies": { "@ts-jison/common": "^0.4.1-alpha.1" } }, "sha512-5C1Wr+wixAzn2MOFtgy7KbT6N6j9mhmbjAtyvOqZKsikKtNOQj22MM5HxT+ooRexG2NbtxnDSXYdhHR1Lg58ow=="],
|
||||
|
||||
"@ts-jison/parser": ["@ts-jison/parser@0.4.1-alpha.1", "", { "dependencies": { "@ts-jison/common": "^0.4.1-alpha.1", "@ts-jison/lexer": "^0.4.1-alpha.1" } }, "sha512-xNj+qOez/7dju44LlYiTlCjxMzW5oek9EckUAElfln/GBK9vgMSk0swWcnacMr0TYbGjUQuXvL2wEgmDf5WajQ=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||
|
||||
"@types/concat-stream": ["@types/concat-stream@1.6.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-eHE4cQPoj6ngxBZMvVf6Hw7Mh4jMW4U9lpGmS5GBPB9RYxlFg+CHaVN7ErNY4W9XfLIEn20b4VDYaIrbq0q4uA=="],
|
||||
|
||||
"@types/form-data": ["@types/form-data@0.0.33", "", { "dependencies": { "@types/node": "*" } }, "sha512-8BSvG1kGm83cyJITQMZSulnl6QV8jqAGreJsc5tPu1Jq0vTSOiY/k24Wx82JRpWwZSqrala6sd5rWi6aNXvqcw=="],
|
||||
|
||||
"@types/http-link-header": ["@types/http-link-header@1.0.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-snm5oLckop0K3cTDAiBnZDy6ncx9DJ3mCRDvs42C884MbVYPP74Tiq2hFsSDRTyjK6RyDYDIulPiW23ge+g5Lw=="],
|
||||
|
||||
"@types/jsonld": ["@types/jsonld@1.5.15", "", {}, "sha512-PlAFPZjL+AuGYmwlqwKEL0IMP8M8RexH0NIPGfCVWSQ041H2rR/8OlyZSD7KsCVoN8vCfWdtWDBxX8yBVP+xow=="],
|
||||
|
||||
"@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="],
|
||||
|
||||
"@types/qs": ["@types/qs@6.15.1", "", {}, "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw=="],
|
||||
|
||||
"@types/shexj": ["@types/shexj@2.1.7", "", {}, "sha512-pu/0vIZxFTMPVjTlo5MJKFkBL/EbAuFhtCXpmBB7ZdUiyNpc6pt8GxfyRPqdf6q2SsWu71a/vbhvGK2IZN2Eug=="],
|
||||
|
||||
"alien-signals": ["alien-signals@2.0.8", "", {}, "sha512-844G1VLkk0Pe2SJjY0J8vp8ADI73IM4KliNu2OGlYzWpO28NexEUvjHTcFjFX3VXoiUtwTbHxLNI9ImkcoBqzA=="],
|
||||
|
||||
"asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="],
|
||||
|
||||
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
|
||||
|
||||
"async-proxy": ["async-proxy@0.4.1", "", { "dependencies": { "object-path-operator": "^3.0.0" } }, "sha512-4e+zNtoGL4+cnqib8v169CnKcRfAsAubp2EsjBhAA5jyW7jjI3t36rVvuqLwmhtliwf8JvSnxinE4ecQN+DK4w=="],
|
||||
|
||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||
|
||||
"caseless": ["caseless@0.12.0", "", {}, "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw=="],
|
||||
|
||||
"colors-cli": ["colors-cli@1.0.33", "", { "bin": { "colors": "bin/colors" } }, "sha512-PWGsmoJFdOB0t+BeHgmtuoRZUQucOLl5ii81NBzOOGVxlgE04muFNHlR5j8i8MKbOPELBl3243AI6lGBTj5ICQ=="],
|
||||
|
||||
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||
|
||||
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||
|
||||
"concat-stream": ["concat-stream@1.6.2", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^2.2.2", "typedarray": "^0.0.6" } }, "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw=="],
|
||||
|
||||
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
|
||||
|
||||
"cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="],
|
||||
|
||||
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||
|
||||
"dts-dom": ["dts-dom@3.6.0", "", {}, "sha512-on5jxTgt+A6r0Zyyz6ZRHXaAO7J1VPnOd6+AmvI1vH440AlAZZNc5rUHzgPuTjGlrVr1rOWQYNl7ZJK6rDohbw=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
|
||||
|
||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||
|
||||
"filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
|
||||
|
||||
"form-data": ["form-data@2.5.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35", "safe-buffer": "^5.2.1" } }, "sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA=="],
|
||||
|
||||
"fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-port": ["get-port@3.2.0", "", {}, "sha512-x5UJKlgeUiNT8nyo/AcnwLnZuZNcSjSw0kogRB+Whd1fjjFq4B1hySFxSFWWSn4mIBzg3sRNUDFYc4g5gjPoLg=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
|
||||
|
||||
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
|
||||
|
||||
"hierarchy-closure": ["hierarchy-closure@1.2.2", "", {}, "sha512-ZqZvsA6HyMqrmm49D3llYA8x8hqdyDDEkaTXcqwyO+fGQlzxoeXws/5ze11M40s4EoTw7GFxdTKIwj5YDOicLQ=="],
|
||||
|
||||
"http-basic": ["http-basic@8.1.3", "", { "dependencies": { "caseless": "^0.12.0", "concat-stream": "^1.6.2", "http-response-object": "^3.0.1", "parse-cache-control": "^1.0.1" } }, "sha512-/EcDMwJZh3mABI2NhGfHOGOeOZITqfkEO4p/xK+l3NpyncIHUQBoMvCSF/b5GqvKtySC2srL/GGG3+EtlqlmCw=="],
|
||||
|
||||
"http-link-header": ["http-link-header@1.1.3", "", {}, "sha512-3cZ0SRL8fb9MUlU3mKM61FcQvPfXx2dBrZW3Vbg5CXa8jFlK8OaEpePenLe1oEXQduhz8b0QjsqfS59QP4AJDQ=="],
|
||||
|
||||
"http-response-object": ["http-response-object@3.0.2", "", { "dependencies": { "@types/node": "^10.0.3" } }, "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
||||
|
||||
"jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="],
|
||||
|
||||
"jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
|
||||
"jsonld-context-parser": ["jsonld-context-parser@2.4.0", "", { "dependencies": { "@types/http-link-header": "^1.0.1", "@types/node": "^18.0.0", "cross-fetch": "^3.0.6", "http-link-header": "^1.0.2", "relative-to-absolute-iri": "^1.0.5" }, "bin": { "jsonld-context-parse": "bin/jsonld-context-parse.js" } }, "sha512-ZYOfvh525SdPd9ReYY58dxB3E2RUEU4DJ6ZibO8AitcowPeBH4L5rCAitE2om5G1P+HMEgYEYEr4EZKbVN4tpA=="],
|
||||
|
||||
"jsonld2graphobject": ["jsonld2graphobject@0.0.5", "", { "dependencies": { "@rdfjs/types": "^1.0.1", "@types/jsonld": "^1.5.6", "jsonld-context-parser": "^2.1.5", "uuid": "^8.3.2" } }, "sha512-5BqfXOq96+OBjjiJNG8gQH66pYt6hW88z2SJxdvFJo4XNoVMvqAcUz+JSm/KEWS5NLRnebApEzFrYP3HUiUmYw=="],
|
||||
|
||||
"loading-cli": ["loading-cli@1.1.2", "", { "dependencies": { "colors-cli": "^1.0.26" } }, "sha512-M1ntfXHpdGoQxfaqKBOQPwSrTr9EIoTgj664Q9UVSbSnJvAFdribo+Ij//1jvACgrGHaTvfKoD9PG3NOxGj44g=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
|
||||
|
||||
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"object-path-operator": ["object-path-operator@3.0.0", "", {}, "sha512-Z7dlPUeXqRU/lLfGerP24dPC66n7ehyXaTM81k71EFlsaaEjOHkf4/uq1WGicfGfiO7snYShneE1YZZUkyRiLQ=="],
|
||||
|
||||
"parse-cache-control": ["parse-cache-control@1.0.1", "", {}, "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"playwright": ["playwright@1.61.1", "", { "dependencies": { "playwright-core": "1.61.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ=="],
|
||||
|
||||
"playwright-core": ["playwright-core@1.61.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="],
|
||||
|
||||
"prettier": ["prettier@3.8.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q=="],
|
||||
|
||||
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
|
||||
|
||||
"promise": ["promise@8.3.0", "", { "dependencies": { "asap": "~2.0.6" } }, "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg=="],
|
||||
|
||||
"qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="],
|
||||
|
||||
"rdf-data-factory": ["rdf-data-factory@1.1.3", "", { "dependencies": { "@rdfjs/types": "^1.0.0" } }, "sha512-ny6CI7m2bq4lfQQmDYvcb2l1F9KtGwz9chipX4oWu2aAtVoXjb7k3d8J1EsgAsEbMXnBipB/iuRen5H2fwRWWQ=="],
|
||||
|
||||
"readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||
|
||||
"relative-to-absolute-iri": ["relative-to-absolute-iri@1.0.8", "", {}, "sha512-U1TmhrhCmXKkDL9mI8gBbF5TN6TKcuv28k5+H3gMCAjoz0TyyHAICHlaGDZsTEBSu2Y3HhDKc8e6X9n33qeIqA=="],
|
||||
|
||||
"relativize-url": ["relativize-url@0.1.0", "", {}, "sha512-YXet4a9wQP96Ru9MQSfoRUzsCaeboLPXj+rVG1ulH4t54zqFHiNmW6FPl7V2dTxk9uHlW3yb9+1jWO44AdWisw=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="],
|
||||
|
||||
"side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
|
||||
|
||||
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
||||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"sync-request": ["sync-request@6.1.0", "", { "dependencies": { "http-response-object": "^3.0.1", "sync-rpc": "^1.2.1", "then-request": "^6.0.0" } }, "sha512-8fjNkrNlNCrVc/av+Jn+xxqfCjYaBoHqCsDz6mt030UMxJGr+GSfCV1dQt2gRtlL63+VPidwDVLr7V2OcTSdRw=="],
|
||||
|
||||
"sync-rpc": ["sync-rpc@1.3.6", "", { "dependencies": { "get-port": "^3.1.0" } }, "sha512-J8jTXuZzRlvU7HemDgHi3pGnh/rkoqR/OZSjhTyyZrEkkYQbk7Z33AXp37mkPfPpfdOuj7Ex3H/TJM1z48uPQw=="],
|
||||
|
||||
"then-request": ["then-request@6.0.2", "", { "dependencies": { "@types/concat-stream": "^1.6.0", "@types/form-data": "0.0.33", "@types/node": "^8.0.0", "@types/qs": "^6.2.31", "caseless": "~0.12.0", "concat-stream": "^1.6.0", "form-data": "^2.2.0", "http-basic": "^8.1.1", "http-response-object": "^3.0.1", "promise": "^8.0.0", "qs": "^6.4.0" } }, "sha512-3ZBiG7JvP3wbDzA9iNY5zJQcHL4jn/0BWtXIkagfz7QgOL/LqjCEOBQuJNZfu0XYnv5JhKh+cDxCPM4ILrqruA=="],
|
||||
|
||||
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
||||
|
||||
"typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
||||
|
||||
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||
|
||||
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||
|
||||
"http-response-object/@types/node": ["@types/node@10.17.60", "", {}, "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw=="],
|
||||
|
||||
"jsonld-context-parser/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
|
||||
|
||||
"readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
|
||||
"string_decoder/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
|
||||
"then-request/@types/node": ["@types/node@8.10.66", "", {}, "sha512-tktOkFUA4kXx2hhhrB8bIFb5TbwzS4uOhKEmwiD+NoiL0qtP2OQ9mFldbgD4dV1djrlBYP6eBuQZiWjuHUpqFw=="],
|
||||
|
||||
"jsonld-context-parser/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||
}
|
||||
}
|
||||
@@ -43,10 +43,13 @@ in the shim (see the two-axes section in [`simulation.md`](./simulation.md)).
|
||||
resolvers) is designed to survive that swap unchanged.
|
||||
|
||||
### 3. Drop the resolver / shim
|
||||
The `sharedWalletShim` (account → 3 scope-document NURIs, RDF in the private store)
|
||||
has no target equivalent — the target has no central directory. Remove it:
|
||||
`store-registry.ts`, `configureStoreRegistry`, the shim SPARQL. Cross-wallet reads
|
||||
replace the fan-out; per-user wallets replace the shared one.
|
||||
The `sharedWalletShim` (account → 3 scope-document NURIs, held in a subscribable
|
||||
doc-shim reached via a write-once pointer in the store-root — see
|
||||
[`nextgraph-current-state.md`](./nextgraph-current-state.md) § *The pointer → doc-shim
|
||||
indirection*) has no target equivalent — the target has no central directory. Remove
|
||||
it entirely: `store-registry.ts`, `configureStoreRegistry`, the pointer + doc-shim
|
||||
resolution, and the `pointerGuard` dep. Cross-wallet reads replace the fan-out;
|
||||
per-user wallets replace the shared one.
|
||||
|
||||
### 4. Real inbox → drop the in-lib read emulation
|
||||
Replace the emulated `inbox.ts` deposit (`docs.sparqlUpdate` into a shared-wallet
|
||||
|
||||
@@ -217,6 +217,74 @@ broker sync (the `OpenRepo` TODO at `verifier.rs:1423`). Opening still requires
|
||||
repo's NURI + ReadCap — there is no store-level read inheritance (see
|
||||
§ Capability / ReadCap granularity).
|
||||
|
||||
### Findable-without-lookup vs subscribable (first-`State` barrier) — DISJOINT
|
||||
|
||||
Two properties a fresh session might want from a document, and **no single document
|
||||
has both**:
|
||||
|
||||
- **Findable without a lookup.** The ONLY NURI a fresh session can NAME with nothing
|
||||
but the session in hand is the store-root, `did:ng:${privateStoreId}` (from
|
||||
`session.private_store_id`). Any per-document repo is `did:ng:o:<RepoID>` with a
|
||||
**random** RepoID minted by `doc_create` — **not derivable**, so it must be looked
|
||||
up somewhere first.
|
||||
- **Subscribable with a sync BARRIER.** `doc_subscribe(nuri)` delivers `TabInfo` then
|
||||
an initial **`State`** (`verifier.rs:470`/`:476`); that first `State` is the sync
|
||||
barrier — **after it, presence is guaranteed and absence is definitive** (pinned
|
||||
empirically by CONTRACT 3 in `packages/client/e2e/`). But this barrier exists only
|
||||
for a repo `doc_subscribe` can open, i.e. a `did:ng:o:<RepoID>` repo. A **store-root
|
||||
has no first-`State` barrier**: an anchored read on it can return 0 rows during
|
||||
sync-lag with no signal distinguishing "still syncing" from "genuinely empty".
|
||||
|
||||
These two are **mutually exclusive**: the guessable target (store-root) is not
|
||||
barrier-authoritative, and the barrier-authoritative target (`o:` repo) is not
|
||||
guessable. **Consequence:** you cannot build a lookup table that is BOTH reachable
|
||||
cold (findable) AND authoritative on a cold read (barrier). A cold "0 rows" read of a
|
||||
store-root graph is therefore fundamentally ambiguous — which is the trap the shim's
|
||||
account map fell into (see next section).
|
||||
|
||||
### The pointer → doc-shim indirection (how the polyfill shim resolves accounts)
|
||||
|
||||
`store-registry.ts` keeps a map `identifier → {docPublic, docProtected, docPrivate}`
|
||||
(the "shim", the account→document trust root). It must be reachable by a fresh
|
||||
reconnecting session (findable) AND authoritative on a cold read (so a fresh page
|
||||
does not mistake sync-lag for "account absent" and PROVISION a fork). Since no single
|
||||
document is both (previous section), the shim uses an **indirection**:
|
||||
|
||||
1. **doc-shim** — a `doc_create`d graph document (`did:ng:o:...`, hence a first-`State`
|
||||
barrier). **All `AccountRecord`s live inside it.** Because it is subscribable, an
|
||||
anchored read behind its `ensureRepoOpen` barrier is **authoritative**: a cold 0
|
||||
means the account is genuinely absent.
|
||||
2. **pointer** — a single well-known, **write-once** triple in the store-root graph,
|
||||
`<urn:ng-eventually:shim:root> <urn:ng-eventually:shim:shimDoc> <docShimNuri>`.
|
||||
The store-root is findable-without-lookup, so a fresh session can always read it;
|
||||
the pointer being the OLDEST, write-once triple in that graph, it is near-always
|
||||
already synced on a cold read.
|
||||
|
||||
**Resolution** (`resolveShimDoc`): read the pointer from the store-root → open the
|
||||
named doc-shim through its barrier (`ensureRepoOpen`) → read the account
|
||||
AUTHORITATIVELY. First login (no pointer): `doc_create` the doc-shim, publish the
|
||||
pointer, done. A **pointer fork** (two devices each writing a pointer before either
|
||||
synced) is reconciled to the lexicographically-smallest doc-shim NURI
|
||||
(content-addressed, so every device converges on the same doc-shim).
|
||||
|
||||
**The account-level retry is GONE.** Before this indirection the shim lived directly
|
||||
in the store-root graph, so an account read had no barrier and a cold 0 was ambiguous;
|
||||
the lib compensated with a **bounded account-level retry** (`provisionRetry` /
|
||||
`resolveAccountReliably`) that re-read the account several times before concluding
|
||||
"new". Moving the records behind the doc-shim barrier makes the account read
|
||||
authoritative on the FIRST read, so **that retry was removed** — a barrier is
|
||||
deterministic where a retry only guessed. The only residual bounded guard is a small
|
||||
re-read of the **pointer** itself (`pointerGuard`, one write-once triple): it can
|
||||
NEVER re-provision or fork an account — at worst it takes a couple extra reads to see
|
||||
a pointer that is still landing.
|
||||
|
||||
**No legacy migration.** A wallet written under the OLD scheme (accounts directly in
|
||||
the store-root graph, no pointer) is NOT recovered: opening it under the current scheme
|
||||
simply provisions a fresh doc-shim, and the pre-indirection store-root records are
|
||||
ignored. This is deliberate — the only such wallets are dev data — so the resolution
|
||||
path carries no legacy-migration step; it always reads the account authoritatively from
|
||||
the doc-shim.
|
||||
|
||||
### The union is read-only — writes must target one document
|
||||
|
||||
`resolve_target_for_sparql(update=true)` returns `InvalidTarget` for `UserSite` /
|
||||
|
||||
+17
-6
@@ -98,12 +98,23 @@ public/protected/private stores — on top of one shared wallet.
|
||||
`docs.docCreate` primitive. The `scope` (`public|protected|private`) is a
|
||||
logical attribute tracked here, not a physical store.
|
||||
- **The `sharedWalletShim`** is the mapping `account → its 3 scope-document
|
||||
NURIs`, persisted as RDF in the shared wallet's private store (the anchor,
|
||||
always known from the session: `RegistrySession.privateStoreId`). That makes
|
||||
identity resolution cross-device: another device opening the same wallet reads
|
||||
the same shim and finds the same accounts. It is the account→document trust root,
|
||||
which is why every untrusted value that reaches its SPARQL is escaped (see
|
||||
SPARQL hardening below).
|
||||
NURIs`. It is persisted as RDF, but **not directly in the store-root graph** — it
|
||||
lives in a subscribable **doc-shim** reached through a write-once **pointer** in the
|
||||
store-root, an indirection forced by a NextGraph fact: "findable-without-lookup"
|
||||
(store-root) and "subscribable / cold-read-authoritative" (`did:ng:o:` repo with a
|
||||
first-`State` barrier) are DISJOINT. The pointer (findable) names the doc-shim
|
||||
(authoritative); resolution reads the pointer from the store-root, opens the doc-shim
|
||||
through its barrier, and reads the account authoritatively — so a fresh reconnecting
|
||||
session never mistakes sync-lag for "account absent" (which would provision a FORK).
|
||||
Full rationale — including why the old account-level retry (`provisionRetry`) is
|
||||
removed (pre-indirection store-root records are NOT recovered; such wallets are dev
|
||||
data and simply get a fresh doc-shim) — is in
|
||||
[`nextgraph-current-state.md`](./nextgraph-current-state.md) §§ *Findable vs
|
||||
subscribable* / *The pointer → doc-shim indirection*. This map is the
|
||||
account→document trust root, which is why every untrusted value that reaches its
|
||||
SPARQL is escaped (see SPARQL hardening below). It makes identity resolution
|
||||
cross-device: another device opening the same wallet reads the same pointer → the
|
||||
same doc-shim → the same accounts.
|
||||
- **Per-entity documents + per-scope index.** `createEntityDoc(id, scope)`
|
||||
makes a dedicated document for one entity (mirrors the target, where each entity
|
||||
is its own document/repo with a future inbox) and appends its NURI to the
|
||||
|
||||
@@ -140,6 +140,72 @@ export async function launchWalletContext(): Promise<BrowserContext> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a BRAND-NEW wallet in a BRAND-NEW profile dir and RETURN the launched
|
||||
* context, without a `.wallet-ready` marker and WITHOUT tearing the context down.
|
||||
* Unlike {@link ensureWallet} (which reuses one persistent dedicated wallet across
|
||||
* runs — so it is always "hot"), this mints a genuinely FRESH wallet each call so
|
||||
* the cold-start (private-store repo not yet in `self.repos`) can be exercised.
|
||||
*
|
||||
* Same headless nextgraph.eu creation + first-login-bootstrap flow as ensureWallet,
|
||||
* but the context stays OPEN and is returned (with its dir) so the caller can then
|
||||
* open the SDK page in the SAME profile — i.e. the very first app session over a
|
||||
* wallet that has never run the app. Caller cleans up ctx + dir.
|
||||
*/
|
||||
export async function createFreshWalletContext(): Promise<{
|
||||
ctx: BrowserContext;
|
||||
dir: string;
|
||||
name: string;
|
||||
}> {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ng-eventually-fresh-"));
|
||||
const name = "ng-fresh-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
||||
const ctx = await chromium.launchPersistentContext(dir, {
|
||||
headless: true,
|
||||
executablePath: resolveChromePath(),
|
||||
args: LAUNCH_ARGS,
|
||||
});
|
||||
const page = ctx.pages()[0] || (await ctx.newPage());
|
||||
page.on("pageerror", () => {});
|
||||
await page.goto("https://nextgraph.eu/", { waitUntil: "domcontentloaded", timeout: 30000 });
|
||||
const createButton = page.getByText("Create Wallet", { exact: true });
|
||||
await createButton.waitFor({ state: "visible", timeout: 15000 });
|
||||
await createButton.click();
|
||||
|
||||
await page.waitForURL("**/account*", { timeout: 15000 }).catch(() => {});
|
||||
const acceptButton = page.getByText("I accept", { exact: true });
|
||||
await acceptButton.waitFor({ state: "visible", timeout: 15000 });
|
||||
await acceptButton.click();
|
||||
|
||||
const usernameInput = page.locator("#username-input");
|
||||
await usernameInput.waitFor({ state: "visible", timeout: 30000 });
|
||||
await usernameInput.fill(name);
|
||||
const passwordInput = page.locator("#password-input");
|
||||
await passwordInput.waitFor({ state: "visible", timeout: 5000 });
|
||||
await passwordInput.fill(WALLET_PASSWORD);
|
||||
|
||||
const submitButton = page.getByText("create my wallet", { exact: false });
|
||||
await submitButton.waitFor({ state: "visible", timeout: 5000 });
|
||||
await submitButton.click();
|
||||
|
||||
await page.waitForURL("**/#/wallet/login", { timeout: 30000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// First login → bootstrap the verifier repos from the broker (this is what a
|
||||
// brand-new wallet does on its very first unlock).
|
||||
const walletLink = page.getByText("Click here to login with your wallet");
|
||||
if (await walletLink.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
await walletLink.click();
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
const loginPassword = page.locator('input[type="password"]');
|
||||
await loginPassword.waitFor({ state: "visible", timeout: 10000 });
|
||||
await loginPassword.fill(WALLET_PASSWORD);
|
||||
await loginPassword.press("Enter");
|
||||
await page.waitForTimeout(10000);
|
||||
await page.close().catch(() => {});
|
||||
return { ctx, dir, name };
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch a persistent context on a FRESH, EMPTY profile dir (its own userDataDir).
|
||||
* Empty local storage ⇒ empty verifier repo cache ⇒ the reconnection cold-start:
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* COLD-START repro — a genuinely FRESH wallet (never ran the app) driving the
|
||||
* shim's first account resolution/provision.
|
||||
*
|
||||
* Unlike `run.ts`, which reuses ONE dedicated wallet (always "hot" — its
|
||||
* private-store repo is already in the verifier's `self.repos`), this mints a
|
||||
* BRAND-NEW wallet + fresh profile per run and opens the SDK page over it as the
|
||||
* very first session. It then, in order:
|
||||
* 1) probes the RAW anchored shim SELECT on `did:ng:${private_store_id}` — the
|
||||
* cold-start bug surfaces here as `RepoNotFound` (the private-store repo not
|
||||
* yet open) rather than a silent 0 rows;
|
||||
* 2) runs `ensureAccount` (resetRegistryCache first) — the app's first-login
|
||||
* bootstrap — and asserts it provisions 3 scope docs WITHOUT throwing;
|
||||
* 3) re-resolves the SAME id from a fresh anchored read and asserts it returns
|
||||
* the SAME docs (real persistence in the shim).
|
||||
*
|
||||
* Expected BEFORE the fix: step 1 throws RepoNotFound; step 2/3 fail to persist.
|
||||
* Expected AFTER the fix: step 1 may still throw (raw, no open), but step 2/3
|
||||
* succeed because ensureAccount opens the anchor repo before read/write.
|
||||
*
|
||||
* Run: `bun run e2e/repro-fresh-wallet.ts`.
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import type { Frame, Page, BrowserContext } from "playwright";
|
||||
import { buildBundle, serveHarness, createFreshWalletContext, setupBrokerPage } from "./broker";
|
||||
|
||||
type Check = { name: string; ok: boolean; detail?: string };
|
||||
const results: Check[] = [];
|
||||
function check(name: string, ok: boolean, detail?: string): void {
|
||||
results.push({ name, ok, detail });
|
||||
console.log(` [${ok ? "PASS" : "FAIL"}] ${name}${detail ? " — " + detail : ""}`);
|
||||
}
|
||||
|
||||
function sdk<T>(frame: Frame, method: string, ...args: unknown[]): Promise<T> {
|
||||
return frame.evaluate(
|
||||
([m, a]) => (window as any).__sdk[m as string](...(a as unknown[])),
|
||||
[method, args] as const,
|
||||
) as Promise<T>;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log("[repro] building SDK page bundle...");
|
||||
buildBundle();
|
||||
const { url, close: closeServer } = await serveHarness();
|
||||
console.log(`[repro] harness served at ${url}`);
|
||||
|
||||
console.log("[repro] creating a BRAND-NEW wallet (fresh profile)...");
|
||||
let ctx: BrowserContext | null = null;
|
||||
let dir: string | null = null;
|
||||
let page: Page | null = null;
|
||||
try {
|
||||
const fresh = await createFreshWalletContext();
|
||||
ctx = fresh.ctx;
|
||||
dir = fresh.dir;
|
||||
console.log(`[repro] fresh wallet: ${fresh.name}`);
|
||||
|
||||
page = await ctx.newPage();
|
||||
page.on("pageerror", (e) => console.error("[iframe error]", e.message));
|
||||
page.on("console", (m) => {
|
||||
if (m.type() === "error") console.error("[iframe console]", m.text());
|
||||
});
|
||||
|
||||
console.log("[repro] opening SDK page over the FRESH wallet (first-ever app session)...");
|
||||
const frame = await setupBrokerPage(page, url);
|
||||
await frame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 });
|
||||
await frame.waitForFunction(() => (window as any).__sdk.status() === "connected", {
|
||||
timeout: 60000,
|
||||
});
|
||||
console.log("[repro] connected. Driving the cold-start shim resolution...");
|
||||
|
||||
// 1) RAW anchored shim probe — the diagnostic. Reports RepoNotFound if the
|
||||
// private-store repo is not yet open in this fresh session.
|
||||
const probe = await sdk<any>(frame, "shimAnchorProbe");
|
||||
console.log(
|
||||
` [DIAG] raw anchored shim read: threw=${probe.threw} error=${probe.error} rows=${probe.rows}`,
|
||||
);
|
||||
|
||||
// 2) ensureAccount — the real bootstrap. This MUST provision cleanly on a fresh
|
||||
// wallet (all 3 docs truthy, no throw). This is the load-bearing assertion.
|
||||
const ensured = await sdk<any>(frame, "coldEnsureAccount", "@cold-user-1");
|
||||
check(
|
||||
"fresh wallet: ensureAccount provisions the account without throwing",
|
||||
!ensured.threw && !!ensured.docPublic && !!ensured.docProtected && !!ensured.docPrivate,
|
||||
ensured.threw
|
||||
? `THREW: ${ensured.error}`
|
||||
: `pub=${String(ensured.docPublic).slice(0, 22)}… prot=${String(ensured.docProtected).slice(0, 22)}…`,
|
||||
);
|
||||
|
||||
// 3) Re-resolve from a FRESH anchored read — proves the shim actually persisted.
|
||||
const verified = await sdk<any>(frame, "verifyShimPersisted", "@cold-user-1");
|
||||
check(
|
||||
"fresh wallet: the provisioned account persists (re-resolves the SAME docs, no RepoNotFound)",
|
||||
!verified.threw &&
|
||||
verified.docPublic === ensured.docPublic &&
|
||||
verified.docProtected === ensured.docProtected &&
|
||||
verified.docPrivate === ensured.docPrivate,
|
||||
verified.threw
|
||||
? `THREW: ${verified.error}`
|
||||
: `same=${verified.docPublic === ensured.docPublic && verified.docProtected === ensured.docProtected}`,
|
||||
);
|
||||
} finally {
|
||||
try { if (page) await page.close(); } catch { /* ignore */ }
|
||||
try { if (ctx) await ctx.close(); } catch { /* ignore */ }
|
||||
try { if (dir) fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
closeServer();
|
||||
}
|
||||
|
||||
const passed = results.filter((r) => r.ok).length;
|
||||
const failed = results.length - passed;
|
||||
console.log(`\n══ cold-start repro: ${passed} passed, ${failed} failed ══`);
|
||||
if (failed > 0) {
|
||||
for (const r of results.filter((x) => !x.ok)) console.log(` - ${r.name}: ${r.detail ?? ""}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("[repro] fatal:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -294,6 +294,47 @@ async function main(): Promise<void> {
|
||||
check("scope resolvers return NURIs (private distinct from protected/public)", !!r.priv && !!r.prot && !!r.pub && r.priv !== r.prot, `priv=${r.priv?.slice(0,16)}… prot=${r.prot?.slice(0,16)}…`);
|
||||
});
|
||||
|
||||
// ── watchShape (reactive useQuery-shaped read) ──────────────────────────
|
||||
// The real cycle: first subscription reads isPending (barrier not yet crossed),
|
||||
// then after the broker sync it reaches isSuccess with the seeded datum present.
|
||||
// A separate empty scope reaches isSuccess with data:[] (synced-but-empty — the
|
||||
// distinction useShape's upgrade will make native, surfaced here from getSyncState).
|
||||
console.log("\n── watchShape (reactive useQuery-shaped read) ──");
|
||||
await step("watchShape: first subscribe isPending → isSuccess with data present", async () => {
|
||||
const h = "cyc" + Date.now();
|
||||
const CLS = "urn:e2e:ws:Event";
|
||||
const seed = await sdk<any>(frame, "watchShapeSeedAndSubscribe", h, CLS);
|
||||
check("first snapshot after subscribe is isPending (barrier not crossed)", seed.initial.isPending === true && seed.initial.isSuccess === false, `initial=${JSON.stringify(seed.initial)}`);
|
||||
// Event-driven: wait for the barrier to cross + the datum to land.
|
||||
await frame.waitForFunction(
|
||||
(hh) => {
|
||||
const s = (window as any).__sdk.watchShapeSnapshot(hh as string);
|
||||
return s && s.isSuccess && s.dataLen >= 1;
|
||||
},
|
||||
h,
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
const snap = await sdkGet<any>(frame, "watchShapeSnapshot", h);
|
||||
check("reaches isSuccess with the seeded datum (titles include 'seeded')", snap.isSuccess && !snap.isPending && !snap.isError && snap.dataLen >= 1 && snap.titles.includes("seeded"), `snap=${JSON.stringify(snap)}`);
|
||||
await sdk(frame, "watchShapeStop", h);
|
||||
});
|
||||
await step("watchShape: empty scope reaches isSuccess with data:[]", async () => {
|
||||
const h = "empty" + Date.now();
|
||||
const CLS = "urn:e2e:ws:Event";
|
||||
await sdk<any>(frame, "watchShapeEmptyStart", h, CLS);
|
||||
await frame.waitForFunction(
|
||||
(hh) => {
|
||||
const s = (window as any).__sdk.watchShapeSnapshot(hh as string);
|
||||
return s && s.isSuccess;
|
||||
},
|
||||
h,
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
const snap = await sdkGet<any>(frame, "watchShapeSnapshot", h);
|
||||
check("empty scope: isSuccess, data:[] (synced-but-empty, not stuck pending)", snap.isSuccess && !snap.isPending && !snap.isError && snap.dataLen === 0, `snap=${JSON.stringify(snap)}`);
|
||||
await sdk(frame, "watchShapeStop", h);
|
||||
});
|
||||
|
||||
// ── caps / read-filter (in-memory cap model) ────────────────────────────
|
||||
console.log("\n── caps / read-filter (in-memory cap model) ──");
|
||||
await step("read-filter: protected hidden from stranger", async () => {
|
||||
|
||||
@@ -25,8 +25,10 @@ import {
|
||||
discovery,
|
||||
storeRegistry,
|
||||
useShape as libUseShape,
|
||||
watchShape,
|
||||
accounts,
|
||||
} from "@ng-eventually/client";
|
||||
import type { ShapeObservable, ShapeQuery } from "@ng-eventually/client";
|
||||
|
||||
const { IdentityStore } = accounts;
|
||||
|
||||
@@ -84,6 +86,13 @@ configureStoreRegistry({
|
||||
},
|
||||
// Identity normalization used as the shim key (lowercase, strip leading `@`).
|
||||
normalizeId: (id: string) => id.trim().replace(/^@/, "").toLowerCase(),
|
||||
// REAL broker: enable the POINTER micro-guard. The account records live in a
|
||||
// subscribable doc-shim reached via a write-once pointer triple in the store-root;
|
||||
// the account read is barrier-authoritative (no account retry). The only residual
|
||||
// store-root sync-lag is the pointer read — this bounded guard re-reads JUST that
|
||||
// one write-once triple on a cold reconnect (CONTRACT 2 non-fork). It can never
|
||||
// provision or fork an account.
|
||||
pointerGuard: { attempts: 8, baseMs: 150, maxStepMs: 2000 },
|
||||
});
|
||||
|
||||
// ── Bridge marshaling note ─────────────────────────────────────────────────
|
||||
@@ -563,6 +572,184 @@ const identity = new IdentityStore(
|
||||
return { priv, prot, pub };
|
||||
},
|
||||
|
||||
/**
|
||||
* COLD-START anchor probe. Runs the EXACT shim SELECT the registry issues,
|
||||
* anchored to `did:ng:${private_store_id}` (the shim anchor), as the very first
|
||||
* thing in a fresh session — BEFORE anything opens that repo. Reports whether the
|
||||
* raw anchored query threw `RepoNotFound` (the cold-start bug: the private-store
|
||||
* repo not yet in `self.repos`) or returned rows. Uses the low-level docs
|
||||
* primitive directly so nothing (open-repo, ensureAccount) opens the repo first.
|
||||
*/
|
||||
async shimAnchorProbe() {
|
||||
const s = session ?? (await sessionReady);
|
||||
const anchor = `did:ng:${s.private_store_id}`;
|
||||
const query =
|
||||
"SELECT ?acc WHERE { GRAPH <" +
|
||||
anchor +
|
||||
"> { ?acc a <urn:ng-eventually:shim:Account> } }";
|
||||
try {
|
||||
const res: any = await docs.sparqlQuery(s.session_id, query, undefined, anchor);
|
||||
const rows = Array.isArray(res) ? res.length : (res?.results?.bindings?.length ?? 0);
|
||||
return { threw: false, error: null, rows, anchor };
|
||||
} catch (e: any) {
|
||||
return { threw: true, error: String(e?.message ?? e), rows: -1, anchor };
|
||||
}
|
||||
},
|
||||
/**
|
||||
* COLD-START account provision. resetRegistryCache then ensureAccount(id) — the
|
||||
* real bootstrap the app runs on first login. On a fresh wallet, if the anchor
|
||||
* repo isn't open, resolveAccount's read AND ensureAccount's provision write both
|
||||
* hit RepoNotFound; the account never persists. Returns the 3 scope docs (all
|
||||
* truthy iff provisioning succeeded) so the runner can gate on real persistence.
|
||||
*/
|
||||
async coldEnsureAccount(id: string) {
|
||||
storeRegistry.resetRegistryCache();
|
||||
try {
|
||||
const rec = await storeRegistry.ensureAccount(id);
|
||||
return {
|
||||
threw: false,
|
||||
error: null,
|
||||
docPublic: rec.docPublic,
|
||||
docProtected: rec.docProtected,
|
||||
docPrivate: rec.docPrivate,
|
||||
};
|
||||
} catch (e: any) {
|
||||
return { threw: true, error: String(e?.message ?? e), docPublic: "", docProtected: "", docPrivate: "" };
|
||||
}
|
||||
},
|
||||
/**
|
||||
* VERIFY the provisioned account actually PERSISTED to the shim: resetRegistryCache
|
||||
* then re-resolve the SAME id via a fresh anchored read. Returns whether the read
|
||||
* threw + the resolved docs. After the fix, on a fresh wallet this returns the SAME
|
||||
* docs coldEnsureAccount minted (real persistence, no RepoNotFound).
|
||||
*/
|
||||
async verifyShimPersisted(id: string) {
|
||||
storeRegistry.resetRegistryCache();
|
||||
try {
|
||||
const rec = await storeRegistry.ensureAccount(id);
|
||||
return { threw: false, error: null, docPublic: rec.docPublic, docProtected: rec.docProtected, docPrivate: rec.docPrivate };
|
||||
} catch (e: any) {
|
||||
return { threw: true, error: String(e?.message ?? e), docPublic: "", docProtected: "", docPrivate: "" };
|
||||
}
|
||||
},
|
||||
|
||||
// ── watchShape (reactive useQuery-shaped read) ───────────────────────────
|
||||
// A minimal SHEX-ish ShapeType pinning rdf:type to an e2e class IRI. watchShape
|
||||
// reads only `.shape`/`.schema[...].predicates[rdf:type].dataTypes[].literals`
|
||||
// to know which @type to keep — generic, no application domain.
|
||||
_wsProbes: {} as Record<
|
||||
string,
|
||||
{ obs: ShapeObservable; initial: ShapeQuery; unsub: () => void }
|
||||
>,
|
||||
/**
|
||||
* REAL-BROKER cycle proof. Under a fresh identity, create ONE protected entity
|
||||
* document carrying an rdf:type=<e2e class> triple, then open a `watchShape` over
|
||||
* (that class shape, "protected"). Capture the FIRST snapshot right after subscribe
|
||||
* (must be isPending) so the caller can then wait event-driven for isSuccess with
|
||||
* the seeded datum present. Returns the handle + the initial snapshot + the seed
|
||||
* doc/type so the runner can assert the data landed.
|
||||
*/
|
||||
async watchShapeSeedAndSubscribe(handle: string, cls: string) {
|
||||
storeRegistry.resetRegistryCache();
|
||||
const id = "@ws-" + handle;
|
||||
setCurrentUser(id);
|
||||
const doc = await storeRegistry.createEntityDoc(id, "protected");
|
||||
const s = await sessionReady;
|
||||
// Seed the entity doc with the shape's type + a title (anchored default graph).
|
||||
await docs.sparqlUpdate(
|
||||
s.session_id,
|
||||
`INSERT DATA { <${doc}> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <${cls}> ; <urn:e2e:ws:title> "seeded" }`,
|
||||
doc,
|
||||
);
|
||||
// Wait until this session sees the index append (data persisted on the broker).
|
||||
for (let i = 0; i < 15; i++) {
|
||||
storeRegistry.resetRegistryCache();
|
||||
const listed = await storeRegistry.listMyEntityDocs(id, "protected");
|
||||
if (listed.includes(doc)) break;
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
const shape = {
|
||||
shape: "urn:e2e:ws:Shape",
|
||||
schema: {
|
||||
"urn:e2e:ws:Shape": {
|
||||
iri: "urn:e2e:ws:Shape",
|
||||
predicates: [
|
||||
{
|
||||
iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
|
||||
readablePredicate: "type",
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
dataTypes: [{ literals: [cls], valType: "iri" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const obs = watchShape(shape, "protected") as ShapeObservable;
|
||||
const unsub = obs.subscribe(() => {});
|
||||
// First snapshot immediately after subscribe — the barrier is not yet crossed
|
||||
// (docs opening via doc_subscribe), so this MUST be pending.
|
||||
const initial = obs.getSnapshot();
|
||||
(window as any).__sdk._wsProbes[handle] = { obs, initial, unsub };
|
||||
// NB: leave the current user SET for the probe's lifetime — watchShape resolves
|
||||
// the scope from getCurrentUser() on every (reactive) refresh, exactly as the app
|
||||
// keeps a stable identity. watchShapeStop clears it.
|
||||
return { doc, cls, initial: { isPending: initial.isPending, isSuccess: initial.isSuccess, dataLen: initial.data.length } };
|
||||
},
|
||||
/** Current snapshot of a watchShape probe (event-driven poll target). */
|
||||
watchShapeSnapshot(handle: string) {
|
||||
const rec = (window as any).__sdk._wsProbes[handle];
|
||||
if (!rec) return null;
|
||||
const snap: ShapeQuery = rec.obs.getSnapshot();
|
||||
return {
|
||||
isPending: snap.isPending,
|
||||
isSuccess: snap.isSuccess,
|
||||
isError: snap.isError,
|
||||
dataLen: snap.data.length,
|
||||
// The seeded title, if the datum is present (proves the real data landed).
|
||||
titles: snap.data.flatMap((d: any) => d.props?.["urn:e2e:ws:title"] ?? []),
|
||||
};
|
||||
},
|
||||
watchShapeStop(handle: string) {
|
||||
const rec = (window as any).__sdk._wsProbes[handle];
|
||||
if (rec) rec.unsub();
|
||||
setCurrentUser(null);
|
||||
},
|
||||
/**
|
||||
* EMPTY-scope proof: a brand-new identity with NO entity docs of `scope`. watchShape
|
||||
* must reach isSuccess with data:[] (synced-but-empty), NOT stay pending. Returns
|
||||
* the handle; poll watchShapeSnapshot for the transition.
|
||||
*/
|
||||
watchShapeEmptyStart(handle: string, cls: string) {
|
||||
storeRegistry.resetRegistryCache();
|
||||
const id = "@ws-empty-" + handle;
|
||||
setCurrentUser(id);
|
||||
const shape = {
|
||||
shape: "urn:e2e:ws:Shape",
|
||||
schema: {
|
||||
"urn:e2e:ws:Shape": {
|
||||
iri: "urn:e2e:ws:Shape",
|
||||
predicates: [
|
||||
{
|
||||
iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
|
||||
readablePredicate: "type",
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
dataTypes: [{ literals: [cls], valType: "iri" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const obs = watchShape(shape, "protected") as ShapeObservable;
|
||||
const unsub = obs.subscribe(() => {});
|
||||
const initial = obs.getSnapshot();
|
||||
(window as any).__sdk._wsProbes[handle] = { obs, initial, unsub };
|
||||
// Keep the identity set for the probe's lifetime (watchShapeStop clears it): a
|
||||
// faithful "empty scope for a real identity", not "no identity at all".
|
||||
return { initial: { isPending: initial.isPending, isSuccess: initial.isSuccess } };
|
||||
},
|
||||
|
||||
// ── caps / read-filter (in-memory cap model) ─────────────────────────────
|
||||
// The read-filter over the injected useShape Set-like. Boundary note: the
|
||||
// caps/read-filter are EMULATED in-memory (CapRegistry) — the real broker does
|
||||
|
||||
@@ -56,17 +56,62 @@ export function enabled(): boolean {
|
||||
* (`getCurrentUser`) — the account/space the operation is scoped under, which is
|
||||
* the discriminating signal for the isolation leak. NOT the physical wallet id
|
||||
* (shared, constant → useless). `(none)` when no identity is set yet (startup).
|
||||
* Exported so every other polyfill-layer log site (store-registry, inbox,
|
||||
* outbox-log, …) shares the exact same identity resolution as the access log,
|
||||
* instead of re-deriving it — see {@link accessLogPrefix}.
|
||||
*/
|
||||
function activeIdentity(): string {
|
||||
export function activeIdentity(): string {
|
||||
return getCurrentUser() ?? "(none)";
|
||||
}
|
||||
|
||||
/**
|
||||
* The common line prefix for every polyfill-layer low-level-data-path log:
|
||||
* `[<identity>][polyfill]` — identity FIRST (the discriminating scan signal),
|
||||
* `[polyfill]` glued right after with no space between the two brackets. Used by
|
||||
* {@link logAccess} itself, by the unified `console.error`s in store-registry.ts /
|
||||
* inbox.ts, and by the BARRIER / stage-resolution / OUTBOX lines (open-repo.ts,
|
||||
* store-registry.ts, outbox-log.ts) — one single prefix builder so every polyfill
|
||||
* log line is visually groupable by identity when scanning a live session.
|
||||
*/
|
||||
export function accessLogPrefix(): string {
|
||||
return "[" + activeIdentity() + "][polyfill]";
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit one CONCISE diagnostic line — ONLY when {@link enabled} — prefixed by
|
||||
* {@link accessLogPrefix}. Used for the precise data-path trace (BARRIER
|
||||
* resolution, per-stage resolution outcome, the empty-OUTBOX line): a single
|
||||
* line per stage/event, never a dump. Callers pass the fully-composed suffix
|
||||
* (e.g. `"BARRIER " + shortNuri(nuri) + " synced (842ms)"`).
|
||||
*/
|
||||
export function logStage(line: string): void {
|
||||
if (!enabled()) return;
|
||||
console.log(accessLogPrefix() + " " + line);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorten a NURI for the access log: the full form (`did:ng:o:<RepoID>:v:<...>`,
|
||||
* ~100 chars) is too verbose to scan. Drop the `did:ng:o:` prefix and the `:v:<...>`
|
||||
* overlay suffix, and keep the first 8 chars of the RepoID + an ellipsis — short but
|
||||
* still identifiable (`vDlwbZio…`). A NURI that doesn't match the expected shape is
|
||||
* returned unchanged (best-effort, this is only a diagnostic label).
|
||||
*/
|
||||
export function shortNuri(nuri: string): string {
|
||||
const withoutPrefix = nuri.replace(/^did:ng:o:/, "");
|
||||
const repoId = withoutPrefix.split(":v:")[0] ?? withoutPrefix;
|
||||
return repoId.length > 8 ? repoId.slice(0, 8) + "…" : repoId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log one document access — but ONLY when {@link enabled}. Off → returns
|
||||
* immediately, prints nothing. Format:
|
||||
* `[<identity>] READ <nuri> (<label>)` — optionally with `<extra>` appended
|
||||
* (e.g. ` → 3 rows`, a strong signal a doc rendered data under an identity that
|
||||
* should see nothing).
|
||||
* `[<identity>][polyfill] READ <shortNuri> (<label>)` — identity FIRST (the
|
||||
* discriminating scan signal), `[polyfill]` glued right after with no space
|
||||
* between the two brackets — optionally with `<extra>` appended (e.g. ` → 3
|
||||
* rows`, a strong signal a doc rendered data under an identity that should see
|
||||
* nothing). The `[polyfill]` tag marks these as SDK-layer access logs (distinct
|
||||
* from the consumer app's own logs). The NURI is shortened by {@link shortNuri}
|
||||
* to keep the line scannable.
|
||||
*/
|
||||
export function logAccess(
|
||||
op: AccessOp,
|
||||
@@ -76,6 +121,6 @@ export function logAccess(
|
||||
): void {
|
||||
if (!enabled()) return;
|
||||
console.log(
|
||||
"[" + activeIdentity() + "] " + op + " " + nuri + " (" + label + ")" + (extra ?? ""),
|
||||
accessLogPrefix() + " " + op + " " + shortNuri(nuri) + " (" + label + ")" + (extra ?? ""),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
|
||||
import * as inbox from "./inbox";
|
||||
import { subscribeDoc } from "./subscribe";
|
||||
import { ensureRepoOpen } from "./open-repo";
|
||||
import { ensureAccount, reservedAccount } from "./store-registry";
|
||||
import { getCaps } from "./polyfill";
|
||||
import type { Nuri, PrincipalId } from "./types";
|
||||
@@ -101,6 +102,17 @@ async function indexInboxNuri(): Promise<Nuri> {
|
||||
return record.docPublic;
|
||||
}
|
||||
|
||||
/**
|
||||
* The NURI of the global discovery-index document (the inbox where submissions
|
||||
* land). Exposed so a reactive reader ({@link watchShape}) that folds discovery
|
||||
* into the public read-set can SUBSCRIBE to this document and re-resolve when a
|
||||
* new public entity is announced. This is exactly {@link watchIndex}'s subscribe
|
||||
* anchor. Removed against real NextGraph along with the special account.
|
||||
*/
|
||||
export async function indexDocNuri(): Promise<Nuri> {
|
||||
return indexInboxNuri();
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a reference to the global discovery index — the SDK act "make this
|
||||
* discoverable". Deposits `ref` into the index document's inbox via
|
||||
@@ -143,6 +155,19 @@ export async function submitToIndex(ref: unknown, opts?: SubmitOptions): Promise
|
||||
*/
|
||||
export async function readIndex(): Promise<IndexEntry[]> {
|
||||
const target = await indexInboxNuri();
|
||||
// COLD-START heal (polyfill-era): on a FRESH session over a persistent wallet the
|
||||
// discovery-index inbox repo is not yet in the verifier's `self.repos`, so the
|
||||
// anchored `inbox.read` below would resolve an unopened repo and silently return 0
|
||||
// deposits — the same self-inflicted cold-read gap `readScopeIndex`/`readUnion`
|
||||
// heal. This is what made the PUBLIC read's discovery fold come back empty on a
|
||||
// reconnect, so a fresh page's home stayed empty for tens of seconds while the doc
|
||||
// slowly synced by other means. Open/subscribe the index repo ONCE and await its
|
||||
// first `State` (the sync barrier) before the anchored read. Idempotent per session;
|
||||
// no-op with the unit fake ng (no `doc_subscribe`). This is done HERE (a cold direct
|
||||
// reader) rather than inside `inbox.read`, because `inbox.watch` already holds the
|
||||
// repo open via its own subscription and must not spawn a second bootstrap open. See
|
||||
// open-repo.ts.
|
||||
await ensureRepoOpen(target);
|
||||
const deposits = await inbox.read(target);
|
||||
const seen = new Set<string>();
|
||||
const entries: IndexEntry[] = [];
|
||||
|
||||
@@ -93,7 +93,11 @@ export async function sparqlQuery(
|
||||
// rows under an identity that should see nothing) can be appended. Skip the
|
||||
// rowCount work entirely when the log is off.
|
||||
if (accessLogEnabled()) {
|
||||
logAccess("READ", anchor ?? "(no anchor)", label, " → " + rowCount(result) + " rows");
|
||||
// `rows` here are raw RDF triple bindings (the SPARQL `?s ?p ?o` result), NOT
|
||||
// domain objects — one document's entity is spread across several triple rows.
|
||||
// Spell that out so the log isn't mistaken for an object count (the app-level
|
||||
// object/shape count is logged separately by useShapeQuery → dataStats).
|
||||
logAccess("READ", anchor ?? "(no anchor)", label, " → " + rowCount(result) + " triple-rows");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -26,8 +26,10 @@
|
||||
|
||||
import { sparqlUpdate, sparqlQuery } from "./docs";
|
||||
import { subscribeDoc } from "./subscribe";
|
||||
import { ensureRepoOpen } from "./open-repo";
|
||||
import { getCurrentUser, getStoreRegistryDeps } from "./polyfill";
|
||||
import { escapeLiteral } from "./sparql";
|
||||
import { accessLogPrefix } from "./access-log";
|
||||
import type { Nuri, PrincipalId } from "./types";
|
||||
|
||||
// --- deposit model --------------------------------------------------------
|
||||
@@ -156,6 +158,13 @@ export async function post(targetInbox: Nuri, opts: PostOptions): Promise<void>
|
||||
*/
|
||||
export async function read(targetInbox: Nuri): Promise<Deposit[]> {
|
||||
const sid = await sessionId();
|
||||
// NOTE: cold-start repo opening is done by the COLD DIRECT readers that need it
|
||||
// (e.g. `discovery.readIndex` → `ensureInboxRepoOpen`), NOT here — `inbox.watch`
|
||||
// already holds the repo open via its own `subscribeDoc`, so opening a second
|
||||
// bootstrap subscription from inside a watch's re-read would be redundant and can
|
||||
// race the watch's own initial-`State` delivery. Keeping `read` a pure anchored
|
||||
// read leaves both callers correct: the watch path stays event-driven, and the
|
||||
// cold direct-read path opens the repo explicitly before calling `read`.
|
||||
// NO explicit `GRAPH <…>` clause — read the anchored DEFAULT graph (see the
|
||||
// note in `post`). The anchor (`targetInbox`) scopes the query to that repo's
|
||||
// default graph, exactly where `post` writes.
|
||||
@@ -188,6 +197,31 @@ export async function read(targetInbox: Nuri): Promise<Deposit[]> {
|
||||
/** Alias for {@link read} — the name that reads as "process the inbox now". */
|
||||
export const materialize = read;
|
||||
|
||||
/**
|
||||
* COLD, BARRIER-GATED read of `targetInbox` — the reliable "process the inbox at
|
||||
* (re)connection" read. Opens/subscribes the inbox repo and AWAITS its first
|
||||
* `State` (the deterministic sync barrier — after it, presence is guaranteed and
|
||||
* absence definitive, {@link ensureRepoOpen}) BEFORE the anchored {@link read}.
|
||||
*
|
||||
* Why this over a plain {@link read}: on a FRESH session over the persistent
|
||||
* wallet (a (re)connection / new page), the inbox repo is not yet in the verifier's
|
||||
* `self.repos`, so a plain anchored `read` resolves an unopened repo and silently
|
||||
* returns 0 deposits — even for a deposit a remote session already synced to the
|
||||
* broker. Gating on the sync barrier makes the read see the synced deposits. This
|
||||
* is the SAME cold-read heal `discovery.readIndex` applies to the index inbox.
|
||||
*
|
||||
* NOT for the `watch` path: {@link watch} already holds the repo open via its own
|
||||
* `subscribeDoc`, so opening a second bootstrap subscription from inside a watch
|
||||
* re-read would be redundant and could race the watch's own initial-`State`
|
||||
* delivery. Use this from a COLD reader (materialize-at-connection), like
|
||||
* `discovery.readIndex` does. Idempotent per session (no polling); a no-op open on
|
||||
* the unit fake-ng path (no `doc_subscribe`) so `bun test` is unaffected.
|
||||
*/
|
||||
export async function readSynced(targetInbox: Nuri): Promise<Deposit[]> {
|
||||
await ensureRepoOpen(targetInbox);
|
||||
return read(targetInbox);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscription over an inbox — **event-driven, not polled**. Subscribes to the
|
||||
* inbox document via {@link subscribeDoc} (the platform's `doc_subscribe` push):
|
||||
@@ -222,7 +256,7 @@ export function watch(
|
||||
onDeposits(deposits);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[inbox] watch read failed:", error);
|
||||
console.error(accessLogPrefix() + " watch read failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
|
||||
export * from "./types";
|
||||
export { useShape } from "./use-shape";
|
||||
export { watchShape } from "./watch-shape";
|
||||
export type { ShapeQuery, ShapeObservable } from "./watch-shape";
|
||||
export { init, initNg } from "./lifecycle";
|
||||
export * as inbox from "./inbox";
|
||||
export * as discovery from "./discovery";
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
|
||||
import { getConfig, getStoreRegistryDeps } from "./polyfill";
|
||||
import { subscribeDoc, type Unsubscribe } from "./subscribe";
|
||||
import { logStage, shortNuri } from "./access-log";
|
||||
import type { Nuri } from "./types";
|
||||
|
||||
/**
|
||||
@@ -91,7 +92,17 @@ let boundSessionId: string | number | null = null;
|
||||
* pathological case (a doc that never pushes), so a read is never blocked forever —
|
||||
* it just proceeds (and yields 0 rows, exactly as before, for a genuinely-absent doc).
|
||||
*/
|
||||
const OPEN_TIMEOUT_MS = 8000;
|
||||
let OPEN_TIMEOUT_MS = 8000;
|
||||
|
||||
/**
|
||||
* Override the bootstrap-open fallback timeout (ms). TEST-ONLY: the timed-out
|
||||
* branch (a doc that never pushes a `State`) is otherwise only reachable after the
|
||||
* 8s production wait, too slow for a unit test. Production never calls this — the
|
||||
* default stands. `resetOpenedRepos` restores the default.
|
||||
*/
|
||||
export function setOpenTimeoutForTests(ms: number): void {
|
||||
OPEN_TIMEOUT_MS = ms;
|
||||
}
|
||||
|
||||
/** Reset the open registry (mainly for tests / a switched wallet). Tears down the
|
||||
* held bootstrap subscriptions so a subsequent open re-subscribes cleanly. */
|
||||
@@ -108,17 +119,7 @@ export function resetOpenedRepos(): void {
|
||||
held.clear();
|
||||
syncState.clear();
|
||||
boundSessionId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal TEST-ONLY — force a nuri into the opened registry with a given sync
|
||||
* state, without going through `doc_subscribe`. Used by `anti-fork.test.ts` to
|
||||
* simulate a timed-out barrier without waiting the full `OPEN_TIMEOUT_MS` (8s).
|
||||
* Do NOT call from production code.
|
||||
*/
|
||||
export function _forceOpenedSyncState(nuri: Nuri, state: SyncState): void {
|
||||
opened.add(nuri);
|
||||
syncState.set(nuri, state);
|
||||
OPEN_TIMEOUT_MS = 8000;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -182,6 +183,11 @@ export async function ensureRepoOpen(nuri: Nuri): Promise<void> {
|
||||
|
||||
// Subscribed, first `State` not yet seen.
|
||||
syncState.set(nuri, "syncing");
|
||||
// Barrier clock: how long the subscribe→first-State (or fallback) round-trip
|
||||
// took, surfaced on the BARRIER trace line below — the most important line in
|
||||
// the whole low-level data-path trace: it distinguishes a genuine absence
|
||||
// (`synced` → a 0-row read means it) from a not-yet-synced read (`timed-out`).
|
||||
const barrierStartedAt = Date.now();
|
||||
|
||||
const p = (async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
@@ -191,6 +197,7 @@ export async function ensureRepoOpen(nuri: Nuri): Promise<void> {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
syncState.set(nuri, "synced");
|
||||
logStage("BARRIER " + shortNuri(nuri) + " synced (" + (Date.now() - barrierStartedAt) + "ms)");
|
||||
resolve();
|
||||
};
|
||||
// Bounded fallback: proceed WITHOUT a `State`, but mark "timed-out" — NOT
|
||||
@@ -200,6 +207,7 @@ export async function ensureRepoOpen(nuri: Nuri): Promise<void> {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
syncState.set(nuri, "timed-out");
|
||||
logStage("BARRIER " + shortNuri(nuri) + " timed-out (" + (Date.now() - barrierStartedAt) + "ms)");
|
||||
resolve();
|
||||
};
|
||||
// The bootstrap subscription is kept ALIVE for the session — holding it open
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* outbox-log — read-only diagnostic inspection of `@ng-org/web`'s offline write
|
||||
* outbox, at session bootstrap (polyfill-era, low-level-data-path trace).
|
||||
*
|
||||
* ── What this surfaces ──────────────────────────────────────────────────────
|
||||
* `@ng-org/web` (the real injected SDK) queues writes made while offline/
|
||||
* disconnected in an "outbox", persisted client-side in `sessionStorage` by the
|
||||
* WASM verifier (see `sdk/rust/src/local_broker.rs` `JsStorageConfig::
|
||||
* get_js_storage_config` in the `nextgraph-rs` core repo — read-only reference,
|
||||
* NOT vendored here). A non-empty outbox at session start is an ANOMALY worth
|
||||
* surfacing unconditionally: it means writes from a previous (disconnected)
|
||||
* session are still queued and haven't reached the broker yet.
|
||||
*
|
||||
* ── sessionStorage key shapes (verified in the core repo, not guessed) ──────
|
||||
* The outbox is keyed per LOCAL PEER id (`peer_id`, the persistent local peer's
|
||||
* pubkey — NOT the ng-eventually shim's `account`/`identity` concept), via two
|
||||
* key families written by `session_write`/read by `session_read`:
|
||||
* - `ng_peer_last_seq@<peerId>` — the peer's last reserved seq number.
|
||||
* - `ng_outboxes@<peerId>@start` — the seq number the outbox starts at.
|
||||
* - `ng_outboxes@<peerId>@<00000-idx>` — one queued (base64url + BARE-encoded)
|
||||
* event per zero-padded index, contiguous from 0 until the first miss (the
|
||||
* exact shape `outbox_read_function` walks — see `local_broker.rs`).
|
||||
* We don't know `peerId` ahead of time (it's internal to the injected SDK), so
|
||||
* we DISCOVER it by scanning `sessionStorage` for `@start` markers instead of
|
||||
* requiring it to be passed in — this also means the probe works unchanged
|
||||
* however many peers/wallets the browser session has touched.
|
||||
*
|
||||
* ── Read-only, defensive, best-effort ────────────────────────────────────────
|
||||
* This NEVER writes or deletes a key (unlike the real `outbox_read_function`,
|
||||
* which drains on read) — it only counts. The queued event bytes are opaque
|
||||
* (BARE-encoded Rust structs, base64url'd); decoding them to report concrete
|
||||
* write TARGETS (topics/docs) would mean duplicating the WASM verifier's wire
|
||||
* format in this polyfill, which is explicitly out of scope (SDK internals live
|
||||
* in the `@ng-eventually/client`-independent core repo, per this repo's
|
||||
* doctrine) — so only the pending COUNT is reported, never fabricated targets.
|
||||
* `sessionStorage` access itself can throw (sandboxed iframe, disabled storage —
|
||||
* see the exact error string handled in the core repo's `main.ts`
|
||||
* `convert_error`), so the whole probe is wrapped in one try/catch: unavailable
|
||||
* → skip silently, never throw into the caller.
|
||||
*
|
||||
* Polyfill-era; removed at the real multi-store migration alongside the rest of
|
||||
* this low-level trace instrumentation.
|
||||
*/
|
||||
|
||||
import { accessLogPrefix, logStage } from "./access-log";
|
||||
|
||||
/** Matches an outbox "start" marker key, capturing the peer id. */
|
||||
const OUTBOX_START_KEY = /^ng_outboxes@(.+)@start$/;
|
||||
|
||||
/** Safety bound on the per-peer index walk, so a corrupted/mocked storage
|
||||
* (e.g. a `@start` marker with no matching index gaps) can't spin forever.
|
||||
* Real outboxes are queued-while-offline writes — nowhere near this size. */
|
||||
const MAX_SCAN_PER_PEER = 10_000;
|
||||
|
||||
/**
|
||||
* Inspect the outbox NOW and log its state — count only, never targets (see
|
||||
* module doc). Non-empty → `console.warn`, ALWAYS printed (anomaly, not gated
|
||||
* by the access-log flag). Empty → a normal {@link logStage} line, gated by the
|
||||
* access-log flag like the rest of the low-level trace. Read-only: never
|
||||
* mutates `sessionStorage`. Never throws.
|
||||
*/
|
||||
export function inspectOutbox(): void {
|
||||
try {
|
||||
const storage = (globalThis as any)?.sessionStorage;
|
||||
if (!storage) return;
|
||||
|
||||
const peers = new Set<string>();
|
||||
const length: number = storage.length ?? 0;
|
||||
for (let i = 0; i < length; i++) {
|
||||
const key = storage.key?.(i);
|
||||
if (!key) continue;
|
||||
const m = OUTBOX_START_KEY.exec(key);
|
||||
const peerId = m?.[1];
|
||||
if (peerId) peers.add(peerId);
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
for (const peer of peers) {
|
||||
let idx = 0;
|
||||
while (idx < MAX_SCAN_PER_PEER) {
|
||||
const idxKey = "ng_outboxes@" + peer + "@" + String(idx).padStart(5, "0");
|
||||
if (storage.getItem(idxKey) === null) break;
|
||||
total++;
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
|
||||
if (total > 0) {
|
||||
// Anomaly: ALWAYS visible, regardless of the access-log flag.
|
||||
console.warn(accessLogPrefix() + " OUTBOX " + total + " pending write(s)");
|
||||
} else {
|
||||
logStage("OUTBOX empty");
|
||||
}
|
||||
} catch {
|
||||
// sessionStorage unavailable / access denied — skip silently. Diagnostic
|
||||
// only, never a hard dependency of the read/write path.
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import type { NgLike, UseShapeLike, PrincipalId } from "./types";
|
||||
import type { RegistrySession } from "./store-registry";
|
||||
import { CapRegistry } from "./caps";
|
||||
import { setAccessLog } from "./access-log";
|
||||
import { inspectOutbox } from "./outbox-log";
|
||||
|
||||
/**
|
||||
* Consumer-injected dependencies of the storeRegistry (polyfill-era). The
|
||||
@@ -25,19 +26,18 @@ export interface StoreRegistryDeps {
|
||||
/** Normalize an identity id for shim keying. Default: trim (identity-ish). */
|
||||
normalizeId?: (id: string) => string;
|
||||
/**
|
||||
* ANTI-FORK budget for account resolution (polyfill-era). Before provisioning
|
||||
* a "missing" account, the registry re-reads its shim record a bounded number
|
||||
* of times with backoff, so a record merely lagging by broker sync (fresh
|
||||
* session over a persistent wallet) is FOUND and REUSED instead of triggering
|
||||
* a second set of scope docs (the account fork). See the note in
|
||||
* store-registry.ts. Only if every attempt still reads 0 do we provision.
|
||||
*
|
||||
* Default (production): a real budget (~8 attempts / ≲8.5s). Set `attempts: 1`
|
||||
* to disable the retry entirely — appropriate for a SYNCHRONOUS in-memory
|
||||
* store (the unit fake `ng`) where a 0-row read is authoritative and the
|
||||
* backoff would only add dead time; there is no sync lag to wait out there.
|
||||
* POINTER micro-guard budget. The account records now live in a subscribable
|
||||
* doc-shim (`did:ng:o:...`) reached through a well-known write-once POINTER triple
|
||||
* in the store-root graph. The doc-shim read is barrier-AUTHORITATIVE, so accounts
|
||||
* need NO retry (this replaces the deleted account-level `provisionRetry`). The
|
||||
* ONLY residual sync-lag window is the store-root pointer read itself — one
|
||||
* write-once triple. This bounded guard re-reads JUST that pointer a few times if a
|
||||
* fresh cold read misses it; it can never provision or fork an account (worst case:
|
||||
* a couple extra reads before an existing pointer is seen). Enable it where the REAL
|
||||
* broker is used (app + e2e). Left UNSET (the default) → `attempts: 1` = single
|
||||
* read, keeping the synchronous unit fakes fast and unchanged.
|
||||
*/
|
||||
provisionRetry?: { attempts?: number; baseMs?: number; maxStepMs?: number };
|
||||
pointerGuard?: { attempts?: number; baseMs?: number; maxStepMs?: number };
|
||||
}
|
||||
|
||||
export interface EventuallyConfig {
|
||||
@@ -64,7 +64,12 @@ export interface EventuallyConfig {
|
||||
|
||||
let cfg: EventuallyConfig | null = null;
|
||||
let currentUser: PrincipalId | null = null;
|
||||
let registryDeps: Required<StoreRegistryDeps> | null = null;
|
||||
/** Required fields of StoreRegistryDeps after defaults are applied. `pointerGuard`
|
||||
* defaults to `{ attempts: 1 }` (single read) when the consumer leaves it unset. */
|
||||
type ResolvedRegistryDeps = Required<
|
||||
Pick<StoreRegistryDeps, "getSession" | "normalizeId" | "pointerGuard">
|
||||
>;
|
||||
let registryDeps: ResolvedRegistryDeps | null = null;
|
||||
/** The emulated ReadCap/WriteCap registry. Empty until the app declares caps;
|
||||
* while it has no read policy the read filter passes through (no regression). */
|
||||
let caps = new CapRegistry();
|
||||
@@ -95,21 +100,35 @@ export function resetConfig(): void {
|
||||
* disappears at migration.
|
||||
*/
|
||||
export function configureStoreRegistry(deps: StoreRegistryDeps): void {
|
||||
// Fire the outbox inspection (Volet 3 of the low-level data-path trace) once,
|
||||
// on the FIRST successful `getSession()` resolution — the most reliable
|
||||
// "a session is established" signal available: every low-level reader/writer
|
||||
// (store-registry, open-repo, read-model, subscribe, inbox) reaches its
|
||||
// session through this SAME injected `getSession`, so wrapping it HERE catches
|
||||
// the first success from whichever caller happens to run first, instead of
|
||||
// tying the probe to one particular call site. Only on SUCCESS (an error
|
||||
// propagates untouched, exactly as before) and only ONCE per
|
||||
// `configureStoreRegistry()` call (a fresh session config → a fresh check).
|
||||
let outboxInspected = false;
|
||||
const getSession = async (): Promise<RegistrySession> => {
|
||||
const session = await deps.getSession();
|
||||
if (!outboxInspected) {
|
||||
outboxInspected = true;
|
||||
inspectOutbox();
|
||||
}
|
||||
return session;
|
||||
};
|
||||
registryDeps = {
|
||||
getSession: deps.getSession,
|
||||
getSession,
|
||||
normalizeId: deps.normalizeId ?? ((id: string) => id.trim()),
|
||||
// Production default: a real anti-fork budget. Consumers over a synchronous
|
||||
// store pass `{ attempts: 1 }` to opt out (see StoreRegistryDeps).
|
||||
provisionRetry: {
|
||||
attempts: deps.provisionRetry?.attempts ?? 8,
|
||||
baseMs: deps.provisionRetry?.baseMs ?? 300,
|
||||
maxStepMs: deps.provisionRetry?.maxStepMs ?? 1500,
|
||||
},
|
||||
// Default: single read (no re-read). Only the real-broker consumers (app + e2e)
|
||||
// opt into the bounded pointer micro-guard; unit fakes stay synchronous.
|
||||
pointerGuard: deps.pointerGuard ?? { attempts: 1 },
|
||||
};
|
||||
}
|
||||
|
||||
/** @internal — used by the storeRegistry to reach its injected dependencies. */
|
||||
export function getStoreRegistryDeps(): Required<StoreRegistryDeps> {
|
||||
export function getStoreRegistryDeps(): ResolvedRegistryDeps {
|
||||
if (!registryDeps) {
|
||||
throw new Error("[ng-eventually] configureStoreRegistry() must be called before use");
|
||||
}
|
||||
|
||||
@@ -9,10 +9,37 @@
|
||||
* physical NextGraph store. Isolation is enforced by the app layer + the
|
||||
* emulated cap registry, not by crypto.
|
||||
*
|
||||
* The mapping (account → its 3 document NURIs) is the **sharedWalletShim**,
|
||||
* persisted as RDF in the shared wallet's private store (the anchor, always
|
||||
* known from the session). That makes login cross-device: another device
|
||||
* opening the same wallet reads the same shim and finds the same accounts.
|
||||
* The mapping (account → its 3 document NURIs) is the **sharedWalletShim**. It
|
||||
* is persisted as RDF, but NOT in the store-root graph anymore — see the
|
||||
* indirection below. That makes login cross-device: another device opening the
|
||||
* same wallet reads the same shim and finds the same accounts.
|
||||
*
|
||||
* ── The indirection: pointer (store-root) → doc-shim (subscribable) ─────────
|
||||
* On the real NextGraph platform "findable-without-lookup" and "subscribable"
|
||||
* are DISJOINT (verified at the source, see docs/nextgraph-current-state.md
|
||||
* § *Findable vs subscribable*):
|
||||
* - the ONLY NURI a fresh session can name WITHOUT a lookup is the store-root
|
||||
* `did:ng:${privateStoreId}` — but a store-root has NO first-`State` sync
|
||||
* BARRIER, so a cold "0 rows" on it is AMBIGUOUS (could be sync-lag, could
|
||||
* be truly empty);
|
||||
* - the ONLY thing that DOES have a first-`State` barrier is a `did:ng:o:<RepoID>`
|
||||
* doc from `doc_create` — but its RepoID is RANDOM, so a fresh session
|
||||
* cannot GUESS it; it must be looked up.
|
||||
* So a purely-barrier shim resolution is impossible: you cannot have a doc that
|
||||
* is both guessable and authoritative on a cold read. The indirection bridges
|
||||
* this: a well-known, write-ONCE **pointer** triple in the store-root names a
|
||||
* **doc-shim** (`did:ng:o:...`) that holds all account records and IS
|
||||
* subscribable. Resolution reads the pointer (a single oldest write-once triple,
|
||||
* near-always synced), then opens the doc-shim through its `ensureRepoOpen`
|
||||
* BARRIER and reads the account AUTHORITATIVELY (0 = genuinely absent).
|
||||
*
|
||||
* A pointer FORK (two devices writing the pointer before either synced) is
|
||||
* reconciled to a canonical doc-shim (lexicographically-smallest NURI) so every
|
||||
* device converges on the SAME doc-shim. This is why the OLD account-level retry
|
||||
* (`resolveAccountReliably` / `provisionRetry`) is GONE: the account read is now
|
||||
* barrier-authoritative, so it never needs to be retried to distinguish sync-lag
|
||||
* from absence. A micro-guard remains ONLY on the pointer read (one write-once
|
||||
* triple) — see resolvePointer.
|
||||
*
|
||||
* ── Generic by construction ──────────────────────────────────────────────
|
||||
* This module knows only the three native scopes; it knows no application
|
||||
@@ -35,32 +62,8 @@
|
||||
import { docCreate, sparqlUpdate, sparqlQuery } from "./docs";
|
||||
import { getStoreRegistryDeps } from "./polyfill";
|
||||
import { ensureRepoOpen } from "./open-repo";
|
||||
|
||||
// --- provisioning anti-fork guard: barrier-first (polyfill-era) ------------
|
||||
//
|
||||
// The shim (account→docs trust root) lives in the shared wallet's PRIVATE store.
|
||||
// On a fresh session over a persistent wallet, that repo may not have synced yet
|
||||
// from the broker: a targeted account resolve would then read 0 rows even though
|
||||
// the account WAS persisted in an earlier session. If ensureAccount took that 0
|
||||
// at face value it would RE-PROVISION a second set of scope documents — an account
|
||||
// FORK: one session writes/reads one set, another (or the same after a cache drop)
|
||||
// the other, EMPTY set → the user "loses" their data on reconnect.
|
||||
//
|
||||
// Guard (barrier-first): BEFORE reading the shim, open/subscribe the private-store
|
||||
// repo and AWAIT its first `State` push — the deterministic sync barrier (CONTRACT 3
|
||||
// in e2e/). After the barrier, presence is GUARANTEED and absence DEFINITIVE. Then
|
||||
// read the shim ONCE: 0 rows = account genuinely absent → provision exactly once;
|
||||
// rows present = account found → reuse it (NO-FORK). No retry loop, no poll.
|
||||
//
|
||||
// Timed-out barrier (conservative): if `ensureRepoOpen` exits via the 8-second
|
||||
// fallback timeout (getSyncState → "timed-out") the sync is UNCONFIRMED — we do NOT
|
||||
// provision blindly (that would re-fork). Instead we surface a clear error so the
|
||||
// caller can retry the full flow rather than silently creating a duplicate account.
|
||||
// The timeout is rare (pathological broker) and a hard error there is far safer than
|
||||
// a silent fork. Disappears at the real multi-store migration (per-user store NURIs
|
||||
// make this moot — barrier becomes the native store-open which is always confirmed).
|
||||
import { getSyncState } from "./open-repo";
|
||||
import { escapeLiteral, escapeIri, assertNuri } from "./sparql";
|
||||
import { accessLogPrefix, logStage, shortNuri } from "./access-log";
|
||||
import type { Nuri, Scope } from "./types";
|
||||
|
||||
// --- sharedWalletShim model ----------------------------------------------
|
||||
@@ -87,6 +90,18 @@ const P = {
|
||||
// documents (one per entity) that live "in" that scope.
|
||||
const INDEX_SUBJECT = `${SHIM}:index`;
|
||||
|
||||
// --- pointer (store-root → doc-shim indirection) --------------------------
|
||||
//
|
||||
// The pointer is a SINGLE well-known triple written ONCE into the store-root
|
||||
// graph on the very first login, then IMMUTABLE. Its object is the NURI of the
|
||||
// doc-shim (a `did:ng:o:...` repo) where all AccountRecords actually live. The
|
||||
// store-root is NOT subscribable (no first-`State` barrier), but the pointer is
|
||||
// the OLDEST triple in that graph and is write-once, so it is near-always synced
|
||||
// on a cold read — and even a transient miss is bounded by a small guard
|
||||
// (resolvePointer), NOT by an account-level retry.
|
||||
const POINTER_SUBJECT = `${SHIM}:root`;
|
||||
const POINTER_PRED = `${SHIM}:shimDoc`;
|
||||
|
||||
function accountSubject(id: string): string {
|
||||
// The id is UNTRUSTED and lands in an IRI position. Percent-encode it
|
||||
// (escapeIri) so no `>` / `"` / whitespace / control char can break out of
|
||||
@@ -135,7 +150,7 @@ function accountKey(id: string): string {
|
||||
/** Minimal session shape the registry needs — provided by the consumer. */
|
||||
export interface RegistrySession {
|
||||
sessionId: string;
|
||||
/** The shared wallet's private store id — the shim anchor. */
|
||||
/** The shared wallet's private store id — the pointer anchor. */
|
||||
privateStoreId: string;
|
||||
/** The shared wallet's protected store id (native store). Optional: only the
|
||||
* scope resolvers need it; the shim only needs the private anchor. */
|
||||
@@ -152,8 +167,11 @@ async function session(): Promise<RegistrySession> {
|
||||
return getStoreRegistryDeps().getSession();
|
||||
}
|
||||
|
||||
/** The shim lives in the shared wallet's private store (always-known anchor). */
|
||||
async function anchorNuri(): Promise<Nuri> {
|
||||
/** The pointer lives in the shared wallet's private STORE-ROOT graph (the only
|
||||
* always-known-without-lookup anchor). NOT subscribable — hence the pointer is
|
||||
* a write-once triple, and the actual account records live in the doc-shim it
|
||||
* names (see rootNuri vs the doc-shim). */
|
||||
async function rootNuri(): Promise<Nuri> {
|
||||
const s = await session();
|
||||
return `did:ng:${s.privateStoreId}`;
|
||||
}
|
||||
@@ -169,10 +187,20 @@ let cache: Map<string, AccountRecord> | null = null;
|
||||
// targeted resolve never forces a full shim scan. Both are cleared together.
|
||||
const accountCache = new Map<string, AccountRecord>();
|
||||
|
||||
// The resolved doc-shim NURI for the current session (cached: the pointer read +
|
||||
// barrier open happen once, then every account read reuses this doc). Cleared on
|
||||
// resetRegistryCache / wallet switch.
|
||||
let shimDocNuri: Nuri | null = null;
|
||||
// De-dupe concurrent pointer-resolutions so a fresh page firing many parallel
|
||||
// ensureAccount/resolveAccount calls opens the doc-shim exactly once.
|
||||
let shimDocInFlight: Promise<Nuri> | null = null;
|
||||
|
||||
/** Reset cache (e.g. after switching the shared wallet). Mostly for tests. */
|
||||
export function resetRegistryCache(): void {
|
||||
cache = null;
|
||||
accountCache.clear();
|
||||
shimDocNuri = null;
|
||||
shimDocInFlight = null;
|
||||
}
|
||||
|
||||
// --- SPARQL result helpers ------------------------------------------------
|
||||
@@ -192,149 +220,159 @@ function bindingValue(row: Record<string, { value: string }>, key: string): stri
|
||||
return row[key]?.value ?? "";
|
||||
}
|
||||
|
||||
// --- shim load / account bootstrap ----------------------------------------
|
||||
/**
|
||||
* DETERMINISTIC resolution of an account's scope docs from a set of SPARQL
|
||||
* bindings (all bindings for ONE account subject).
|
||||
*
|
||||
* ── Why this is load-bearing (residual fork residue) ───────────────────────
|
||||
* A corrupted shim can carry the SAME account subject with MULTIPLE values for a
|
||||
* scope predicate (e.g. 5 `shim:docPublic`) — the residue of past account FORKS
|
||||
* (each stray provision appended another doc NURI). A query then returns several
|
||||
* bindings (the cross-product of the duplicate values). Picking `rows[0]` is
|
||||
* NON-DETERMINISTIC (binding order is not stable across sessions), so the session
|
||||
* that WROTE an entity into one docPublic and a later fresh page that RESOLVED a
|
||||
* DIFFERENT docPublic would disagree → the anchored `readScopeIndex` returns 0 →
|
||||
* the home reads empty. When both happen to pick the same doc, it "works".
|
||||
*
|
||||
* The fix: for each scope field, collect EVERY distinct value across the bindings
|
||||
* and choose the SAME one every time — the lexicographically-smallest NURI. NURIs
|
||||
* are content-addressed and stable, so lexicographic order is a total, stable,
|
||||
* session-independent order: writer and reader converge on the SAME canonical doc
|
||||
* even on a wallet already corrupted by duplicates. (No creation timestamp is
|
||||
* recorded in the shim, so lexicographic-min is the available deterministic key.)
|
||||
*
|
||||
* With the barrier-authoritative doc-shim read, account FORKS no longer occur (a
|
||||
* fresh page reads the doc-shim through its first-`State` barrier, so a cold 0 is
|
||||
* definitive and never triggers a fork-provision). `canonicalDoc` is RETAINED to
|
||||
* stay robust against the residue of PAST forks already persisted in a wallet, and
|
||||
* to reconcile a benign pointer fork the same content-addressed way.
|
||||
*/
|
||||
function canonicalDoc(rows: Array<Record<string, { value: string }>>, key: string): Nuri {
|
||||
let chosen = "";
|
||||
const distinct = new Set<string>();
|
||||
for (const row of rows) {
|
||||
const v = bindingValue(row, key);
|
||||
if (!v) continue;
|
||||
distinct.add(v);
|
||||
if (chosen === "" || v < chosen) chosen = v;
|
||||
}
|
||||
// Stage trace: which doc got picked, and out of how many DISTINCT candidate
|
||||
// values — >1 flags residual fork residue (see the module doc above) even
|
||||
// when resolution still converges correctly on the canonical (smallest) one.
|
||||
logStage(
|
||||
"canonicalDoc(" + key + ") → " + (chosen ? shortNuri(chosen) : "none") +
|
||||
" (" + distinct.size + (distinct.size === 1 ? " candidate)" : " candidates)"),
|
||||
);
|
||||
return chosen;
|
||||
}
|
||||
|
||||
/** Load all accounts from the shim into the cache. */
|
||||
export async function loadShim(): Promise<Map<string, AccountRecord>> {
|
||||
if (cache) return cache;
|
||||
/** Build an AccountRecord by picking the canonical (lexicographically-smallest)
|
||||
* doc NURI per scope across all bindings for one account. See {@link canonicalDoc}. */
|
||||
function recordFromRows(
|
||||
rows: Array<Record<string, { value: string }>>,
|
||||
fallbackId: string,
|
||||
): AccountRecord {
|
||||
let id = "";
|
||||
for (const row of rows) {
|
||||
const v = bindingValue(row, "id");
|
||||
if (v) { id = v; break; }
|
||||
}
|
||||
return {
|
||||
id: id || fallbackId,
|
||||
docPublic: canonicalDoc(rows, "docPublic"),
|
||||
docProtected: canonicalDoc(rows, "docProtected"),
|
||||
docPrivate: canonicalDoc(rows, "docPrivate"),
|
||||
};
|
||||
}
|
||||
|
||||
// --- pointer resolution + doc-shim bootstrap ------------------------------
|
||||
|
||||
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/**
|
||||
* Read the pointer(s) from the store-root graph → the canonical doc-shim NURI, or
|
||||
* `""` if no pointer exists yet.
|
||||
*
|
||||
* The store-root is NOT subscribable, so this read has no first-`State` barrier:
|
||||
* a cold 0 is ambiguous. But the pointer is ONE write-once triple (the OLDEST in
|
||||
* that graph), so it is near-always synced. The ONLY residual guard is a small
|
||||
* bounded re-read here (NOT an account retry): a handful of quick re-reads of that
|
||||
* single triple. It is bounded, benign, and — crucially — it can never re-provision
|
||||
* an account or fork data; the worst it can do is take a couple extra reads to see a
|
||||
* pointer that is still landing. The account records themselves are read
|
||||
* authoritatively through the doc-shim barrier, never through this guard.
|
||||
*
|
||||
* If MULTIPLE pointers exist (a pointer fork: two devices each wrote a pointer to
|
||||
* their own freshly-created doc-shim before either synced), reconcile to the
|
||||
* canonical (lexicographically-smallest) doc-shim NURI — content-addressed and
|
||||
* stable, so every device converges on the SAME doc-shim.
|
||||
*/
|
||||
async function resolvePointer(): Promise<Nuri> {
|
||||
const s = await session();
|
||||
const anchor = await anchorNuri();
|
||||
const root = await rootNuri();
|
||||
// COLD-START heal: open the store-root repo before the anchored read, so a fresh
|
||||
// wallet whose store-root isn't yet in `self.repos` resolves instead of throwing
|
||||
// `RepoNotFound`. Idempotent; a no-op with the unit fake ng. The store-root has no
|
||||
// barrier, so this open cannot make the read authoritative — the guard below does.
|
||||
await ensureRepoOpen(root);
|
||||
const query = `
|
||||
SELECT ?id ?docPublic ?docProtected ?docPrivate WHERE {
|
||||
GRAPH <${assertNuri(anchor)}> {
|
||||
?acc a <${P.type}> ;
|
||||
<${P.id}> ?id ;
|
||||
<${P.docPublic}> ?docPublic ;
|
||||
<${P.docProtected}> ?docProtected ;
|
||||
<${P.docPrivate}> ?docPrivate .
|
||||
SELECT ?shimDoc WHERE {
|
||||
GRAPH <${assertNuri(root)}> {
|
||||
<${POINTER_SUBJECT}> <${POINTER_PRED}> ?shimDoc .
|
||||
}
|
||||
}`;
|
||||
const map = new Map<string, AccountRecord>();
|
||||
try {
|
||||
const result = await sparqlQuery(s.sessionId, query, undefined, anchor, "loadShim");
|
||||
for (const row of readBindings(result)) {
|
||||
const id = bindingValue(row, "id");
|
||||
if (!id) continue;
|
||||
const key = accountKey(id);
|
||||
const record: AccountRecord = {
|
||||
id,
|
||||
docPublic: bindingValue(row, "docPublic"),
|
||||
docProtected: bindingValue(row, "docProtected"),
|
||||
docPrivate: bindingValue(row, "docPrivate"),
|
||||
};
|
||||
map.set(key, record);
|
||||
// Feed the per-account cache too, so a subsequent targeted resolve is free.
|
||||
accountCache.set(key, record);
|
||||
|
||||
// Micro-guard (POINTER only): a small bounded re-read to bridge the store-root
|
||||
// sync-lag window on the ONE write-once pointer triple. Bounded, and it can only
|
||||
// ever DELAY seeing an existing pointer — never provision, never fork. Uses the
|
||||
// injected pointerGuard budget (defaults to a single read when unset, so unit
|
||||
// fakes stay synchronous). NB this is NOT the deleted account-level provisionRetry.
|
||||
const budget = getStoreRegistryDeps().pointerGuard;
|
||||
const attempts = Math.max(1, budget.attempts ?? 1);
|
||||
const baseMs = budget.baseMs ?? 150;
|
||||
const maxStepMs = budget.maxStepMs ?? 2000;
|
||||
|
||||
let step = baseMs;
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
try {
|
||||
const result = await sparqlQuery(s.sessionId, query, undefined, root, "resolvePointer");
|
||||
const doc = canonicalDoc(readBindings(result), "shimDoc");
|
||||
if (doc) {
|
||||
logStage("resolvePointer → 1 target: " + shortNuri(doc));
|
||||
return doc;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(accessLogPrefix() + " resolvePointer failed:", error);
|
||||
}
|
||||
if (i < attempts - 1) {
|
||||
await sleep(step);
|
||||
step = Math.min(step * 2, maxStepMs);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[storeRegistry] loadShim failed:", error);
|
||||
}
|
||||
cache = map;
|
||||
return map;
|
||||
logStage("resolvePointer → 0 targets");
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve ONE account by its shim key with a BOUNDED query — O(1), independent
|
||||
* of the number of accounts in the shim. This is the HOT-PATH lookup: it hits
|
||||
* the account record at its known subject (`accountSubject(id)`) directly,
|
||||
* instead of scanning EVERY account like {@link loadShim}. Returns the account's
|
||||
* record or `null` if it does not exist yet.
|
||||
*
|
||||
* Cached per account (in `accountCache`); a hit skips the query entirely, so
|
||||
* repeated resolves of the same account are free. `resetRegistryCache` clears it.
|
||||
*/
|
||||
export async function resolveAccount(id: string): Promise<AccountRecord | null> {
|
||||
const key = accountKey(id);
|
||||
const cached = accountCache.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
/** Write the pointer (store-root → doc-shim), once, at first login. Idempotent in
|
||||
* practice (only called when no pointer was found); a concurrent double-write is
|
||||
* reconciled by canonicalDoc on read. */
|
||||
async function writePointer(doc: Nuri): Promise<void> {
|
||||
const s = await session();
|
||||
const anchor = await anchorNuri();
|
||||
// `subj` is already IRI-safe (accountSubject → escapeIri); `anchor` is a
|
||||
// trusted-shaped NURI → assertNuri. The query is bounded to this one subject.
|
||||
const subj = accountSubject(id);
|
||||
const query = `
|
||||
SELECT ?id ?docPublic ?docProtected ?docPrivate WHERE {
|
||||
GRAPH <${assertNuri(anchor)}> {
|
||||
<${subj}> a <${P.type}> ;
|
||||
<${P.id}> ?id ;
|
||||
<${P.docPublic}> ?docPublic ;
|
||||
<${P.docProtected}> ?docProtected ;
|
||||
<${P.docPrivate}> ?docPrivate .
|
||||
const root = await rootNuri();
|
||||
await ensureRepoOpen(root);
|
||||
const update = `
|
||||
INSERT DATA {
|
||||
GRAPH <${assertNuri(root)}> {
|
||||
<${POINTER_SUBJECT}> <${POINTER_PRED}> <${assertNuri(doc)}> .
|
||||
}
|
||||
}`;
|
||||
try {
|
||||
const result = await sparqlQuery(s.sessionId, query, undefined, anchor, "resolveAccount");
|
||||
const rows = readBindings(result);
|
||||
if (rows.length === 0) return null;
|
||||
const row = rows[0]!;
|
||||
const record: AccountRecord = {
|
||||
id: bindingValue(row, "id") || id,
|
||||
docPublic: bindingValue(row, "docPublic"),
|
||||
docProtected: bindingValue(row, "docProtected"),
|
||||
docPrivate: bindingValue(row, "docPrivate"),
|
||||
};
|
||||
accountCache.set(key, record);
|
||||
return record;
|
||||
await sparqlUpdate(s.sessionId, update, root, "writePointer");
|
||||
} catch (error) {
|
||||
console.error("[storeRegistry] resolveAccount failed:", error);
|
||||
return null;
|
||||
console.error(accessLogPrefix() + " writePointer failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve ONE account after waiting for the private-store sync barrier, so a
|
||||
* 0-row result is DEFINITIVE (account genuinely absent) rather than ambiguous
|
||||
* (broker lag). This is the ANTI-FORK guard for the polyfill-era shim.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Cache hit → return immediately (session-idempotent, no I/O).
|
||||
* 2. Open/subscribe the private-store repo (`did:ng:${privateStoreId}`) and
|
||||
* await the first `State` push — the deterministic sync barrier (CONTRACT 3).
|
||||
* After the barrier, presence is guaranteed and absence definitive.
|
||||
* 3. If the barrier TIMED-OUT (getSyncState → "timed-out"), the sync is
|
||||
* unconfirmed — provisioning here would risk a fork. Throw a clear error
|
||||
* instead so the caller can retry the full login flow. This is the
|
||||
* conservative-safe choice: a hard error is recoverable; a silent fork is not.
|
||||
* 4. Read the shim ONCE. 0 rows = genuinely absent → caller provisions exactly
|
||||
* once. Rows present → reuse (NO-FORK preserved).
|
||||
*
|
||||
* With the unit fake `ng` (no `doc_subscribe`), `ensureRepoOpen` is a no-op
|
||||
* (getSyncState → "unknown") and the single read is immediate — synchronous
|
||||
* behaviour preserved, no lag to wait out.
|
||||
*/
|
||||
async function resolveAccountReliably(id: string): Promise<AccountRecord | null> {
|
||||
// Cache hit → session-idempotent, no query, no fork risk.
|
||||
const cached = accountCache.get(accountKey(id));
|
||||
if (cached) return cached;
|
||||
|
||||
// The shim lives in the private store. Open/subscribe it and await the first
|
||||
// `State` push (the sync barrier). NURI: `did:ng:${session.privateStoreId}`.
|
||||
const nuri = await anchorNuri();
|
||||
await ensureRepoOpen(nuri);
|
||||
|
||||
// Conservative timed-out guard: if the barrier did not confirm sync,
|
||||
// provisioning blind risks creating a fork. Raise a clear error — the caller
|
||||
// (or the app's retry-login flow) should re-attempt when the broker is reachable.
|
||||
// "unknown" means the fake-ng no-op path: no barrier semantics → safe to read.
|
||||
const syncResult = getSyncState(nuri);
|
||||
if (syncResult === "timed-out") {
|
||||
throw new Error(
|
||||
"[storeRegistry] sync barrier timed out for the private store — " +
|
||||
"shim state unconfirmed, refusing to provision to avoid account fork. " +
|
||||
"Retry when the broker is reachable.",
|
||||
);
|
||||
}
|
||||
|
||||
// Barrier reached (or fake-ng "unknown" path): single read, result is definitive.
|
||||
return resolveAccount(id);
|
||||
}
|
||||
|
||||
/** All known accounts (from the shim). */
|
||||
export async function allAccounts(): Promise<AccountRecord[]> {
|
||||
return [...(await loadShim()).values()];
|
||||
}
|
||||
|
||||
/** Create one graph document in the shared wallet's private store (→ a NURI). */
|
||||
async function createDoc(): Promise<Nuri> {
|
||||
const s = await session();
|
||||
@@ -343,59 +381,258 @@ async function createDoc(): Promise<Nuri> {
|
||||
return docCreate(s.sessionId, "Graph", "data:graph", "store", undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve (or on first login, create) the doc-shim NURI for this session — the
|
||||
* `did:ng:o:...` repo that holds every AccountRecord and IS subscribable.
|
||||
*
|
||||
* Steps (cached; runs at most once per session, concurrent callers share one):
|
||||
* 1. Read the pointer from the store-root (resolvePointer). If present → that is
|
||||
* the doc-shim; open it through its first-`State` BARRIER so subsequent account
|
||||
* reads are AUTHORITATIVE.
|
||||
* 2. No pointer → FIRST login:
|
||||
* a. create a fresh doc-shim (`doc_create`), which bootstraps the repo into the
|
||||
* session (`self.repos`) — so it is already open/synced in-session;
|
||||
* b. publish the pointer (store-root → the new doc-shim), once;
|
||||
* c. open it (barrier — trivially satisfied for a just-created in-session repo).
|
||||
* The BARRIER matters on RECONNECT (step 1, reading an EXISTING remote doc-shim);
|
||||
* on first-login creation it is a no-op, so the pointer is published first.
|
||||
*/
|
||||
async function resolveShimDoc(): Promise<Nuri> {
|
||||
if (shimDocNuri) return shimDocNuri;
|
||||
if (shimDocInFlight) return shimDocInFlight;
|
||||
|
||||
const p = (async (): Promise<Nuri> => {
|
||||
const existing = await resolvePointer();
|
||||
if (existing) {
|
||||
// Open the doc-shim through its first-`State` barrier BEFORE any account read,
|
||||
// so a cold 0 on the doc-shim is authoritative (genuinely absent), not sync-lag.
|
||||
await ensureRepoOpen(existing);
|
||||
shimDocNuri = existing;
|
||||
logStage("resolveShimDoc → " + shortNuri(existing));
|
||||
return existing;
|
||||
}
|
||||
|
||||
// FIRST login (no pointer): create the doc-shim (bootstrapped in-session), publish
|
||||
// the pointer, then open (no-op barrier for a just-created repo).
|
||||
const doc = await createDoc();
|
||||
await writePointer(doc);
|
||||
await ensureRepoOpen(doc);
|
||||
shimDocNuri = doc;
|
||||
logStage("resolveShimDoc → " + shortNuri(doc));
|
||||
return doc;
|
||||
})();
|
||||
|
||||
shimDocInFlight = p;
|
||||
try {
|
||||
return await p;
|
||||
} finally {
|
||||
shimDocInFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- shim load / account bootstrap ----------------------------------------
|
||||
|
||||
/** Load all accounts from the shim (the doc-shim) into the cache. */
|
||||
export async function loadShim(): Promise<Map<string, AccountRecord>> {
|
||||
if (cache) return cache;
|
||||
const s = await session();
|
||||
const doc = await resolveShimDoc();
|
||||
const query = `
|
||||
SELECT ?id ?docPublic ?docProtected ?docPrivate WHERE {
|
||||
?acc a <${P.type}> ;
|
||||
<${P.id}> ?id ;
|
||||
<${P.docPublic}> ?docPublic ;
|
||||
<${P.docProtected}> ?docProtected ;
|
||||
<${P.docPrivate}> ?docPrivate .
|
||||
}`;
|
||||
const map = new Map<string, AccountRecord>();
|
||||
// The doc-shim is opened (first-`State` barrier) by resolveShimDoc, so this read is
|
||||
// authoritative.
|
||||
await ensureRepoOpen(doc);
|
||||
try {
|
||||
const result = await sparqlQuery(s.sessionId, query, undefined, doc, "loadShim");
|
||||
// Group ALL bindings by account key first, then pick the CANONICAL doc per
|
||||
// scope (see recordFromRows / canonicalDoc). A single account subject may carry
|
||||
// duplicate scope-doc values (fork residue) → several bindings; grouping +
|
||||
// canonical selection makes loadShim resolve the SAME doc the targeted
|
||||
// resolveAccount does, so full-scan and hot-path readers never disagree.
|
||||
const byKey = new Map<string, Array<Record<string, { value: string }>>>();
|
||||
for (const row of readBindings(result)) {
|
||||
const id = bindingValue(row, "id");
|
||||
if (!id) continue;
|
||||
const key = accountKey(id);
|
||||
const bucket = byKey.get(key) ?? [];
|
||||
bucket.push(row);
|
||||
byKey.set(key, bucket);
|
||||
}
|
||||
for (const [key, rows] of byKey) {
|
||||
const record = recordFromRows(rows, rows[0] ? bindingValue(rows[0], "id") : key);
|
||||
map.set(key, record);
|
||||
// Feed the per-account cache too, so a subsequent targeted resolve is free.
|
||||
accountCache.set(key, record);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(accessLogPrefix() + " loadShim failed:", error);
|
||||
}
|
||||
cache = map;
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve ONE account by its shim key with a BOUNDED query — O(1), independent
|
||||
* of the number of accounts in the shim. This is the HOT-PATH lookup: it hits
|
||||
* the account record at its known subject (`accountSubject(id)`) directly in the
|
||||
* doc-shim, instead of scanning EVERY account like {@link loadShim}. Returns the
|
||||
* account's record or `null` if it does not exist yet.
|
||||
*
|
||||
* ── Barrier-AUTHORITATIVE (the reconnection fix) ────────────────────────────
|
||||
* The read targets the DOC-SHIM (`did:ng:o:...`), which resolveShimDoc opened
|
||||
* through its first-`State` barrier. So a cold 0 rows here is DEFINITIVE ("account
|
||||
* genuinely absent"), not ambiguous sync-lag — no account-level retry is needed or
|
||||
* used. This is what replaced the old `resolveAccountReliably` / `provisionRetry`
|
||||
* loop: the store-root ambiguity that forced the retry is gone once the read moves
|
||||
* behind the doc-shim barrier.
|
||||
*
|
||||
* Cached per account (in `accountCache`); a hit skips the query entirely, so
|
||||
* repeated resolves of the same account are free. `resetRegistryCache` clears it.
|
||||
*/
|
||||
export async function resolveAccount(id: string): Promise<AccountRecord | null> {
|
||||
const key = accountKey(id);
|
||||
const cached = accountCache.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const s = await session();
|
||||
const doc = await resolveShimDoc();
|
||||
// `subj` is already IRI-safe (accountSubject → escapeIri). The read is anchored to
|
||||
// the doc-shim's default graph (opened through its barrier by resolveShimDoc), so
|
||||
// it is authoritative. The query is bounded to this one subject.
|
||||
const subj = accountSubject(id);
|
||||
const query = `
|
||||
SELECT ?id ?docPublic ?docProtected ?docPrivate WHERE {
|
||||
<${subj}> a <${P.type}> ;
|
||||
<${P.id}> ?id ;
|
||||
<${P.docPublic}> ?docPublic ;
|
||||
<${P.docProtected}> ?docProtected ;
|
||||
<${P.docPrivate}> ?docPrivate .
|
||||
}`;
|
||||
try {
|
||||
const result = await sparqlQuery(s.sessionId, query, undefined, doc, "resolveAccount");
|
||||
const rows = readBindings(result);
|
||||
if (rows.length === 0) {
|
||||
logStage("resolveAccount(" + key + ") → null");
|
||||
return null;
|
||||
}
|
||||
// DETERMINISTIC: a corrupted shim may return SEVERAL bindings for this one
|
||||
// account subject (duplicate scope-doc values from past forks). Pick the
|
||||
// canonical (lexicographically-smallest) doc per scope so writer and reader
|
||||
// always resolve the SAME docPublic (robustness against PAST fork residue).
|
||||
const record = recordFromRows(rows, id);
|
||||
accountCache.set(key, record);
|
||||
logStage("resolveAccount(" + key + ") → 1 record");
|
||||
return record;
|
||||
} catch (error) {
|
||||
console.error(accessLogPrefix() + " resolveAccount failed:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** All known accounts (from the shim). */
|
||||
export async function allAccounts(): Promise<AccountRecord[]> {
|
||||
return [...(await loadShim()).values()];
|
||||
}
|
||||
|
||||
/** Persist one AccountRecord into the doc-shim (anchored default-graph write, the
|
||||
* canonical always-safe shape — same convention as createEntityDoc). */
|
||||
async function writeRecord(doc: Nuri, record: AccountRecord): Promise<void> {
|
||||
const s = await session();
|
||||
const subj = `${SHIM}:account:${escapeIri(accountKey(record.id))}`;
|
||||
// `subj` is IRI-safe (escapeIri). `id` is UNTRUSTED text in a LITERAL position →
|
||||
// escapeLiteral. The doc NURIs come from `ng` but are stored as literals here, so
|
||||
// they are escaped as literals too (defence in depth). NO explicit `GRAPH <…>`
|
||||
// wrapper: write the anchored DEFAULT graph (the `doc` anchor scopes it) — the
|
||||
// canonical, always-safe shape the anchored default-graph read queries match.
|
||||
const update = `
|
||||
INSERT DATA {
|
||||
<${subj}> a <${P.type}> ;
|
||||
<${P.id}> "${escapeLiteral(record.id)}" ;
|
||||
<${P.docPublic}> "${escapeLiteral(record.docPublic)}" ;
|
||||
<${P.docProtected}> "${escapeLiteral(record.docProtected)}" ;
|
||||
<${P.docPrivate}> "${escapeLiteral(record.docPrivate)}" .
|
||||
}`;
|
||||
try {
|
||||
await sparqlUpdate(s.sessionId, update, doc, "writeRecord");
|
||||
} catch (error) {
|
||||
console.error(accessLogPrefix() + " writeRecord persist failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-flight `ensureAccount` promises, keyed by account key — so CONCURRENT
|
||||
* `ensureAccount(id)` calls for the SAME account share ONE resolve-or-provision.
|
||||
*
|
||||
* ── Why this is load-bearing (concurrent-provision de-dup) ──────────────────
|
||||
* On a fresh page over the persistent wallet, SEVERAL independent callers hit
|
||||
* `ensureAccount(A)` near-simultaneously (watchShape public/protected, the container
|
||||
* subscriptions, the app's owned-events effect). With the barrier-authoritative
|
||||
* resolveAccount a fresh page NO LONGER mistakes sync-lag for absence — but if the
|
||||
* account is GENUINELY new, N concurrent callers would still each see 0 and each
|
||||
* provision a set of scope docs (an in-session fork). De-duping concurrent provisions
|
||||
* collapses those N into ONE: the first caller resolves-or-provisions; every
|
||||
* concurrent caller awaits the SAME promise and gets the SAME record. Not polling: a
|
||||
* bounded in-memory promise map (mirrors open-repo.ts `inFlight`), cleared the instant
|
||||
* it settles.
|
||||
*/
|
||||
const ensureInFlight = new Map<string, Promise<AccountRecord>>();
|
||||
|
||||
/**
|
||||
* Ensure an account exists in the shim, creating its 3 scope documents on
|
||||
* first sight. Idempotent — returns the existing record if already present.
|
||||
* Concurrency-safe: concurrent calls for the same account share one provision
|
||||
* (see {@link ensureInFlight}) so a fresh page never FORKS the account.
|
||||
*/
|
||||
export async function ensureAccount(id: string): Promise<AccountRecord> {
|
||||
const key = accountKey(id);
|
||||
// HOT PATH: targeted O(1) lookup — does THIS account already exist? — instead
|
||||
// of a full-shim scan (loadShim). Off the read/write hot path entirely.
|
||||
//
|
||||
// ANTI-FORK (polyfill-era): open the private-store repo and await its first
|
||||
// `State` push (the sync barrier) BEFORE reading the shim, so a 0-row result
|
||||
// is DEFINITIVE rather than a lag artifact. Only after the barrier do we treat
|
||||
// 0 rows as "genuinely new" and provision. A cache hit inside
|
||||
// resolveAccountReliably keeps same-session resolves free and deterministic
|
||||
// (the cached record wins over any re-provision). Throws if the barrier timed
|
||||
// out — safer than provisioning blind and creating a fork.
|
||||
const existing = await resolveAccountReliably(id);
|
||||
if (existing) return existing;
|
||||
// A completed provision/resolve is cached → no query, no fork risk.
|
||||
const cached = accountCache.get(key);
|
||||
if (cached) return cached;
|
||||
// A concurrent provision for the SAME account is already running → await it,
|
||||
// instead of racing a second (forking) provision. This is the anti-fork guard.
|
||||
const pending = ensureInFlight.get(key);
|
||||
if (pending) return pending;
|
||||
|
||||
const [docPublic, docProtected, docPrivate] = await Promise.all([
|
||||
createDoc(),
|
||||
createDoc(),
|
||||
createDoc(),
|
||||
]);
|
||||
const record: AccountRecord = { id, docPublic, docProtected, docPrivate };
|
||||
const p = (async (): Promise<AccountRecord> => {
|
||||
// HOT PATH: targeted O(1) lookup — does THIS account already exist? — instead
|
||||
// of a full-shim scan (loadShim). Off the read/write hot path entirely.
|
||||
//
|
||||
// Barrier-AUTHORITATIVE: resolveAccount reads the doc-shim behind its first-`State`
|
||||
// barrier (opened by resolveShimDoc), so a 0 here means the account is GENUINELY
|
||||
// absent — not sync-lag. No account-level retry: the store-root ambiguity that
|
||||
// forced the old provisionRetry loop is gone once the read moves behind the barrier.
|
||||
const existing = await resolveAccount(id);
|
||||
if (existing) return existing;
|
||||
|
||||
const s = await session();
|
||||
const anchor = await anchorNuri();
|
||||
const subj = accountSubject(id);
|
||||
// `subj` is already IRI-safe (accountSubject → escapeIri). `anchor` is a
|
||||
// trusted-shaped NURI → assertNuri. `id` is UNTRUSTED text in a LITERAL
|
||||
// position → escapeLiteral. The doc NURIs come from `ng` but are stored as
|
||||
// literals here, so they are escaped as literals too (defence in depth).
|
||||
const update = `
|
||||
INSERT DATA {
|
||||
GRAPH <${assertNuri(anchor)}> {
|
||||
<${subj}> a <${P.type}> ;
|
||||
<${P.id}> "${escapeLiteral(id)}" ;
|
||||
<${P.docPublic}> "${escapeLiteral(docPublic)}" ;
|
||||
<${P.docProtected}> "${escapeLiteral(docProtected)}" ;
|
||||
<${P.docPrivate}> "${escapeLiteral(docPrivate)}" .
|
||||
}
|
||||
}`;
|
||||
const doc = await resolveShimDoc();
|
||||
const [docPublic, docProtected, docPrivate] = await Promise.all([
|
||||
createDoc(),
|
||||
createDoc(),
|
||||
createDoc(),
|
||||
]);
|
||||
const record: AccountRecord = { id, docPublic, docProtected, docPrivate };
|
||||
// Persist the record INTO the doc-shim (not the store-root anymore).
|
||||
await writeRecord(doc, record);
|
||||
// Feed the per-account cache, and the full-shim cache if it is already loaded
|
||||
// (so allAccounts / the fan-out see the freshly-created account too).
|
||||
accountCache.set(key, record);
|
||||
cache?.set(key, record);
|
||||
return record;
|
||||
})();
|
||||
|
||||
ensureInFlight.set(key, p);
|
||||
try {
|
||||
await sparqlUpdate(s.sessionId, update, anchor, "ensureAccount");
|
||||
} catch (error) {
|
||||
console.error("[storeRegistry] ensureAccount persist failed:", error);
|
||||
return await p;
|
||||
} finally {
|
||||
ensureInFlight.delete(key);
|
||||
}
|
||||
// Feed the per-account cache, and the full-shim cache if it is already loaded
|
||||
// (so allAccounts / the fan-out see the freshly-created account too).
|
||||
accountCache.set(key, record);
|
||||
cache?.set(key, record);
|
||||
return record;
|
||||
}
|
||||
|
||||
// --- resolvers ------------------------------------------------------------
|
||||
@@ -476,12 +713,11 @@ const INBOX_ANCHOR_ACCOUNT = reservedAccount("inbox");
|
||||
* This is a DEDICATED inbox DOCUMENT (a reserved account's public scope document —
|
||||
* a real repo NURI from `docCreate`, stable across clients via the shim), NOT the
|
||||
* shared wallet's private-store root. Reason (perf + hygiene): the shim (the
|
||||
* account→document trust root) lives in the private-store graph and is scanned on
|
||||
* every `loadShim`; routing every inbox deposit into that SAME graph bloats it
|
||||
* without bound (thousands of deposit triples across sessions), turning `loadShim`
|
||||
* into a multi-second full-graph scan. A separate inbox document keeps the shim
|
||||
* graph small and the deposits isolated. At migration this becomes the host's
|
||||
* native per-document inbox and the resolution moves here.
|
||||
* account→document trust root) is scanned on every `loadShim`; routing every inbox
|
||||
* deposit into that SAME graph bloats it without bound (thousands of deposit triples
|
||||
* across sessions). A separate inbox document keeps the shim graph small and the
|
||||
* deposits isolated. At migration this becomes the host's native per-document inbox
|
||||
* and the resolution moves here.
|
||||
*/
|
||||
export async function resolveInboxAnchor(): Promise<Nuri> {
|
||||
const record = await ensureAccount(INBOX_ANCHOR_ACCOUNT);
|
||||
@@ -517,7 +753,7 @@ export async function createEntityDoc(id: string, scope: Scope): Promise<Nuri> {
|
||||
"createEntityDoc",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[storeRegistry] createEntityDoc index append failed:", error);
|
||||
console.error(accessLogPrefix() + " createEntityDoc index append failed:", error);
|
||||
}
|
||||
return entityNuri;
|
||||
}
|
||||
@@ -549,8 +785,9 @@ async function readScopeIndex(indexDoc: Nuri): Promise<Nuri[]> {
|
||||
if (v) out.push(v);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[storeRegistry] readScopeIndex read failed:", error);
|
||||
console.error(accessLogPrefix() + " readScopeIndex failed:", error);
|
||||
}
|
||||
logStage("readScopeIndex(" + shortNuri(indexDoc) + ") → " + out.length + " entities");
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -574,6 +811,20 @@ export async function listEntityDocs(scope: Scope): Promise<Nuri[]> {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The scope-INDEX document NURI of ONE account (`id`) for `scope` — the
|
||||
* store-container document that LISTS the account's per-entity document NURIs
|
||||
* (what {@link listMyEntityDocs} reads). Exposed so a reactive reader
|
||||
* ({@link watchShape}) can SUBSCRIBE to this index document and re-resolve the
|
||||
* entity-doc set when the index changes (a new entity created appends a NURI
|
||||
* here). Idempotent via `ensureAccount`'s cache. At migration this becomes the
|
||||
* user's real per-scope store NURI (the container the store itself provides).
|
||||
*/
|
||||
export async function scopeIndexDoc(id: string, scope: Scope): Promise<Nuri> {
|
||||
const record = await ensureAccount(id);
|
||||
return indexDocOf(record, scope);
|
||||
}
|
||||
|
||||
/**
|
||||
* The entity-document NURIs of `scope` belonging to ONE account (`id`) —
|
||||
* the read-by-need path for one account's own entities. Bounded to a SINGLE
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* watch-shape — a REACTIVE, TanStack-`useQuery`-shaped read over one SHEX shape in
|
||||
* one logical scope. This is the surface the consuming app will bind (phase B) with
|
||||
* `useSyncExternalStore` — the polyfill deliberately exposes an OBSERVABLE, never a
|
||||
* React hook (the lib has NO React dependency, same constraint as `subscribe.ts`).
|
||||
*
|
||||
* ── Why an observable, and why this exact shape ────────────────────────────
|
||||
* It anticipates NextGraph's planned `useShape(shape, scope)` upgrade, which will
|
||||
* natively distinguish "sync in progress" from "synced but empty". That distinction
|
||||
* ALREADY exists lib-internally (`open-repo.ts` `getSyncState`: syncing / synced /
|
||||
* timed-out); `watchShape` merely SURFACES it as a `useQuery`-minimal snapshot:
|
||||
* `ShapeQuery<T> = { data: T[]; isPending; isSuccess; isError; error }`.
|
||||
* `data` is ALWAYS an array (never `undefined`), so a synced-but-empty scope reads
|
||||
* `{ data: [], isPending: false, isSuccess: true }` — the key distinction — while a
|
||||
* scope still syncing reads `{ data: [], isPending: true, isSuccess: false }`.
|
||||
*
|
||||
* ── What the observable OWNS (the whole read pipeline) ─────────────────────
|
||||
* 1. Resolve the logical scope → the doc set: the current identity's per-entity
|
||||
* docs for that scope (`storeRegistry.listMyEntityDocs`), PLUS — for `public`
|
||||
* only — the discovery index folded in (`discovery.readIndex`), so the app
|
||||
* never orchestrates discovery to read. Faithful to the future
|
||||
* `useShape(shape, 'public')`.
|
||||
* 2. Open the docs (`ensureReposOpen`) — this AWAITS the sync BARRIER (first
|
||||
* `State` per doc, `getSyncState` → `synced`, or `timed-out` on the bounded
|
||||
* fallback). `isPending` holds until the barrier is reached for the current
|
||||
* doc set AND the first `readUnion` has rendered.
|
||||
* 3. `readUnion(docs)` — the read-model (cap filter already applied inside; we do
|
||||
* NOT double-filter), then FILTER the union by the requested shape's `@type`
|
||||
* (a `readUnion` union spans multiple types; each `watchShape` yields only the
|
||||
* subjects of its shape). Non-domain: the type IRI is read from the SHEX
|
||||
* ShapeType, not from any application concept.
|
||||
*
|
||||
* ── Reactivity WITHOUT polling (no `setInterval`) ──────────────────────────
|
||||
* Reactivity is push-only (rule no-broker-polling): `subscribeDoc` on every doc in
|
||||
* the current set re-runs `readUnion` on any push. The set is DYNAMIC (creating an
|
||||
* entity appends a NURI to the scope-index doc; announcing a public entity appends
|
||||
* to the discovery index), so we ALSO subscribe to the scope-index document (and,
|
||||
* for `public`, the discovery-index document): a push there re-RESOLVES the scope
|
||||
* and re-keys the subscribed set. Subscriptions are idempotent — an already-followed
|
||||
* doc is not re-subscribed. Everything reuses `subscribe.ts` / `open-repo.ts`; no
|
||||
* parallel channel.
|
||||
*
|
||||
* ── timed-out → isSuccess (best-effort), NOT isError ───────────────────────
|
||||
* A doc whose barrier fell back to `timed-out` still counts as "barrier reached"
|
||||
* (`isSuccess`): a slow-but-empty wallet must read as empty-success, not error.
|
||||
* `isError` fires ONLY on a real thrown exception in the pipeline.
|
||||
*/
|
||||
|
||||
import { getCurrentUser } from "./polyfill";
|
||||
import { ensureReposOpen, getSyncState } from "./open-repo";
|
||||
import { readUnion, type UnionSubject } from "./read-model";
|
||||
import { subscribeDoc, type Unsubscribe } from "./subscribe";
|
||||
import { listMyEntityDocs, scopeIndexDoc } from "./store-registry";
|
||||
import { readIndex, indexDocNuri } from "./discovery";
|
||||
import type { Nuri, Scope } from "./types";
|
||||
|
||||
/**
|
||||
* The RDF `type` predicate IRI. A SHEX shape pins its class via a triple
|
||||
* constraint on this predicate; we filter the read union by it.
|
||||
*/
|
||||
const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
|
||||
|
||||
/**
|
||||
* A minimal TanStack-`useQuery`-shaped read snapshot. `data` is ALWAYS an array
|
||||
* (never `undefined`). Defaults `T` to {@link UnionSubject} — `watchShape` yields
|
||||
* the generic per-subject property bags of the read-model (NO application domain);
|
||||
* the app maps them to its own entity types in phase B.
|
||||
*/
|
||||
export interface ShapeQuery<T = UnionSubject> {
|
||||
/** The subjects of the requested shape/scope. Empty array when none (never undefined). */
|
||||
data: T[];
|
||||
/** True while the sync barrier for the current doc set is not yet reached OR the
|
||||
* first `readUnion` has not rendered. Mutually exclusive with `isSuccess`. */
|
||||
isPending: boolean;
|
||||
/** True once the barrier is reached (all docs `synced` OR `timed-out`) AND the
|
||||
* first `readUnion` has rendered. A synced-but-EMPTY scope is `isSuccess` with
|
||||
* `data: []` — the distinction this surface exists for. */
|
||||
isSuccess: boolean;
|
||||
/** True ONLY on a real thrown exception in the read pipeline (never for `timed-out`). */
|
||||
isError: boolean;
|
||||
/** The caught error when `isError`, else `undefined`. */
|
||||
error: unknown;
|
||||
}
|
||||
|
||||
/** The observable a caller binds with `useSyncExternalStore` (phase B). */
|
||||
export interface ShapeObservable<T = UnionSubject> {
|
||||
/** The current snapshot. STABLE across calls until it actually changes (so
|
||||
* `useSyncExternalStore` does not loop): the same reference is returned until a
|
||||
* state transition produces a new one. */
|
||||
getSnapshot(): ShapeQuery<T>;
|
||||
/** Register a change listener; returns an unsubscribe. The last listener's
|
||||
* unsubscribe tears down the underlying doc subscriptions. */
|
||||
subscribe(onChange: () => void): () => void;
|
||||
/** Force a re-resolve + re-read now (e.g. an imperative refresh). Idempotent
|
||||
* w.r.t. subscriptions; never polls. */
|
||||
refetch(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the class IRI(s) a SHEX {@link ShapeType} pins on `rdf:type`, if any.
|
||||
* A generated shape constrains its subject's type via a triple constraint on the
|
||||
* `rdf:type` predicate whose `literals` carry the class IRI(s). Returns the set of
|
||||
* those IRIs, or `null` when the shape pins NO type (then no type-filter is applied
|
||||
* and every subject in the doc set flows through). Purely structural — reads only
|
||||
* the SHEX schema, no application domain.
|
||||
*/
|
||||
function shapeTypeIris(shapeType: unknown): Set<string> | null {
|
||||
try {
|
||||
const st = shapeType as {
|
||||
shape?: string;
|
||||
schema?: Record<string, { predicates?: Array<{ iri?: string; dataTypes?: Array<{ literals?: unknown[] }> }> }>;
|
||||
};
|
||||
const shape = st?.shape && st.schema ? st.schema[st.shape] : undefined;
|
||||
const preds = shape?.predicates ?? [];
|
||||
const iris = new Set<string>();
|
||||
for (const p of preds) {
|
||||
if (p?.iri !== RDF_TYPE) continue;
|
||||
for (const dt of p.dataTypes ?? []) {
|
||||
for (const lit of dt.literals ?? []) {
|
||||
if (typeof lit === "string") iris.add(lit);
|
||||
}
|
||||
}
|
||||
}
|
||||
return iris.size > 0 ? iris : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a subject satisfies the shape's `@type` constraint (or the shape pins none). */
|
||||
function matchesShape(subject: UnionSubject, typeIris: Set<string> | null): boolean {
|
||||
if (!typeIris) return true; // shape pins no rdf:type → accept every subject
|
||||
const types = subject.props[RDF_TYPE] ?? [];
|
||||
return types.some((t) => typeIris.has(t));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the sync BARRIER is reached for the whole doc set. Called only AFTER
|
||||
* `ensureReposOpen(docs)` has resolved, so each doc has been requested; the only
|
||||
* state that still holds the barrier open is `syncing` (subscribed, first `State`
|
||||
* not yet received). `synced` and `timed-out` both count as reached (`timed-out` is
|
||||
* best-effort, not an error). `unknown` means the injected `ng` has no
|
||||
* `doc_subscribe` (the fake/no-op open path, which has NO barrier semantics) — after
|
||||
* a completed open it can only mean that path, so it counts as reached (opened,
|
||||
* nothing to wait on). An EMPTY doc set is trivially past the barrier.
|
||||
*/
|
||||
function barrierReached(docs: Nuri[]): boolean {
|
||||
for (const d of docs) {
|
||||
if (getSyncState(d) === "syncing") return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a reactive, `useQuery`-shaped observable over one SHEX `shapeType` in one
|
||||
* logical `scope` (`'public' | 'protected' | 'private'`). See the module header for
|
||||
* the full pipeline. The returned observable is inert until its first
|
||||
* {@link ShapeObservable.subscribe} (or {@link ShapeObservable.refetch}) — that is
|
||||
* what kicks off resolution, opening and the first read; before then `getSnapshot`
|
||||
* reports the initial pending snapshot.
|
||||
*/
|
||||
export function watchShape<T = UnionSubject>(
|
||||
shapeType: unknown,
|
||||
scope: Scope,
|
||||
): ShapeObservable<T> {
|
||||
const typeIris = shapeTypeIris(shapeType);
|
||||
|
||||
// The current, STABLE snapshot (same reference until a transition rebuilds it).
|
||||
let snapshot: ShapeQuery<UnionSubject> = {
|
||||
data: [],
|
||||
isPending: true,
|
||||
isSuccess: false,
|
||||
isError: false,
|
||||
error: undefined,
|
||||
};
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
let started = false;
|
||||
// The docs currently subscribed for change signals, keyed by NURI → unsubscribe.
|
||||
// Idempotent: a doc already here is not re-subscribed. Excludes the container
|
||||
// (scope-index / discovery-index) subscriptions, held separately.
|
||||
const docSubs = new Map<Nuri, Unsubscribe>();
|
||||
// Container subscriptions (scope-index doc; discovery-index doc for `public`) —
|
||||
// a push here means the doc SET may have changed → re-resolve.
|
||||
const containerSubs = new Map<Nuri, Unsubscribe>();
|
||||
// Monotonic token so a slow in-flight refresh cannot clobber a newer one.
|
||||
let refreshToken = 0;
|
||||
|
||||
function emit(): void {
|
||||
for (const l of listeners) {
|
||||
try {
|
||||
l();
|
||||
} catch (error) {
|
||||
console.error("[watch-shape] listener threw", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setSnapshot(next: ShapeQuery<UnionSubject>): void {
|
||||
snapshot = next;
|
||||
emit();
|
||||
}
|
||||
|
||||
/** Extract candidate document NURIs from an opaque discovery `ref` — every
|
||||
* string, recursively, that looks like a NextGraph doc NURI (`did:ng:`). Generic:
|
||||
* the app puts the entity doc NURI inside the ref it submits; we fold those docs
|
||||
* into the read-set so the app need not orchestrate discovery. Non-NURI refs
|
||||
* contribute nothing (and readUnion+shape-filter drop anything irrelevant). */
|
||||
function nurisFromRef(ref: unknown, out: Set<Nuri>): void {
|
||||
if (typeof ref === "string") {
|
||||
if (ref.startsWith("did:ng:")) out.add(ref);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(ref)) {
|
||||
for (const v of ref) nurisFromRef(v, out);
|
||||
return;
|
||||
}
|
||||
if (ref && typeof ref === "object") {
|
||||
for (const v of Object.values(ref)) nurisFromRef(v, out);
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the logical scope → the current doc set (my entity docs + discovery
|
||||
* fold for `public`). Tolerant: a resolution failure yields whatever resolved. */
|
||||
async function resolveDocs(): Promise<Nuri[]> {
|
||||
const user = getCurrentUser();
|
||||
const set = new Set<Nuri>();
|
||||
if (user) {
|
||||
try {
|
||||
for (const d of await listMyEntityDocs(user, scope)) set.add(d);
|
||||
} catch (error) {
|
||||
console.error("[watch-shape] listMyEntityDocs failed", error);
|
||||
}
|
||||
}
|
||||
if (scope === "public") {
|
||||
try {
|
||||
for (const e of await readIndex()) nurisFromRef(e.ref, set);
|
||||
} catch (error) {
|
||||
console.error("[watch-shape] discovery readIndex failed", error);
|
||||
}
|
||||
}
|
||||
return [...set];
|
||||
}
|
||||
|
||||
/** Subscribe to the CONTAINER documents (scope-index; discovery-index for public)
|
||||
* so a change to the doc SET re-resolves. Idempotent per NURI. */
|
||||
async function ensureContainerSubs(): Promise<void> {
|
||||
const containers: Nuri[] = [];
|
||||
const user = getCurrentUser();
|
||||
if (user) {
|
||||
try {
|
||||
containers.push(await scopeIndexDoc(user, scope));
|
||||
} catch (error) {
|
||||
console.error("[watch-shape] scopeIndexDoc failed", error);
|
||||
}
|
||||
}
|
||||
if (scope === "public") {
|
||||
try {
|
||||
containers.push(await indexDocNuri());
|
||||
} catch (error) {
|
||||
console.error("[watch-shape] indexDocNuri failed", error);
|
||||
}
|
||||
}
|
||||
for (const c of containers) {
|
||||
if (!c || containerSubs.has(c)) continue;
|
||||
// A push on a container doc means the set may have changed → full re-resolve.
|
||||
containerSubs.set(c, subscribeDoc(c, () => void refresh()));
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-key the per-doc change subscriptions to exactly `docs` (idempotent adds,
|
||||
* prune removed). A push on any of these re-reads (data-only, no re-resolve). */
|
||||
function syncDocSubs(docs: Nuri[]): void {
|
||||
const wanted = new Set(docs.filter(Boolean));
|
||||
for (const [nuri, unsub] of docSubs) {
|
||||
if (!wanted.has(nuri)) {
|
||||
try {
|
||||
unsub();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
docSubs.delete(nuri);
|
||||
}
|
||||
}
|
||||
for (const nuri of wanted) {
|
||||
if (docSubs.has(nuri)) continue;
|
||||
docSubs.set(nuri, subscribeDoc(nuri, () => void reread()));
|
||||
}
|
||||
}
|
||||
|
||||
/** Read (union + shape filter) the CURRENT doc set and publish a snapshot.
|
||||
* Derives isPending/isSuccess from the barrier + whether the read rendered. */
|
||||
async function readAndPublish(docs: Nuri[], token: number): Promise<void> {
|
||||
let subjects: UnionSubject[];
|
||||
try {
|
||||
subjects = await readUnion(docs);
|
||||
} catch (error) {
|
||||
if (token !== refreshToken) return;
|
||||
setSnapshot({ data: [], isPending: false, isSuccess: false, isError: true, error });
|
||||
return;
|
||||
}
|
||||
if (token !== refreshToken) return; // superseded by a newer refresh/reread
|
||||
const data = subjects.filter((s) => matchesShape(s, typeIris));
|
||||
const past = barrierReached(docs);
|
||||
setSnapshot({
|
||||
data,
|
||||
isPending: !past,
|
||||
isSuccess: past,
|
||||
isError: false,
|
||||
error: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** Full cycle: resolve the scope, (re)establish container subs, open the docs
|
||||
* (await the barrier), sync per-doc subs, then read + publish. */
|
||||
async function refresh(): Promise<void> {
|
||||
const token = ++refreshToken;
|
||||
try {
|
||||
await ensureContainerSubs();
|
||||
const docs = await resolveDocs();
|
||||
if (token !== refreshToken) return;
|
||||
syncDocSubs(docs);
|
||||
// Open/await the barrier (first State per doc, or timed-out). No-op once open.
|
||||
await ensureReposOpen(docs);
|
||||
if (token !== refreshToken) return;
|
||||
await readAndPublish(docs, token);
|
||||
} catch (error) {
|
||||
if (token !== refreshToken) return;
|
||||
setSnapshot({ data: [], isPending: false, isSuccess: false, isError: true, error });
|
||||
}
|
||||
}
|
||||
|
||||
/** A push on an already-open doc: re-read the CURRENT set only (no re-resolve,
|
||||
* the set is unchanged). Reuses the docs we are subscribed to. */
|
||||
async function reread(): Promise<void> {
|
||||
const token = ++refreshToken;
|
||||
const docs = [...docSubs.keys()];
|
||||
await readAndPublish(docs, token);
|
||||
}
|
||||
|
||||
function start(): void {
|
||||
if (started) return;
|
||||
started = true;
|
||||
void refresh();
|
||||
}
|
||||
|
||||
return {
|
||||
getSnapshot(): ShapeQuery<T> {
|
||||
return snapshot as unknown as ShapeQuery<T>;
|
||||
},
|
||||
subscribe(onChange: () => void): () => void {
|
||||
listeners.add(onChange);
|
||||
start();
|
||||
return () => {
|
||||
listeners.delete(onChange);
|
||||
if (listeners.size === 0) {
|
||||
// Last listener gone → tear down the underlying subscriptions. A later
|
||||
// subscribe restarts a fresh cycle.
|
||||
for (const u of docSubs.values()) {
|
||||
try {
|
||||
u();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
for (const u of containerSubs.values()) {
|
||||
try {
|
||||
u();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
docSubs.clear();
|
||||
containerSubs.clear();
|
||||
started = false;
|
||||
}
|
||||
};
|
||||
},
|
||||
refetch(): void {
|
||||
start();
|
||||
void refresh();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -6,8 +6,10 @@
|
||||
* (a) OFF by default: reads + writes via sparqlQuery / sparqlUpdate / docCreate
|
||||
* emit nothing to console.log.
|
||||
* (b) ON via configure({ debugAccessLog: true }): each read/write emits a line
|
||||
* matching `[<identity>] READ/WRITE <nuri> (<label>)` plus row-count suffix
|
||||
* on READs.
|
||||
* matching `[<identity>][polyfill] READ/WRITE <shortNuri> (<label>)` (identity
|
||||
* FIRST, `[polyfill]` glued right after) plus row-count suffix on READs. The
|
||||
* NURI is shortened by shortNuri (did:ng:o: prefix + :v: suffix stripped,
|
||||
* RepoID truncated to 8 chars + ellipsis).
|
||||
* (c) ON via env var NG_EVENTUALLY_ACCESS_LOG=1: same behavior without changing
|
||||
* calling code.
|
||||
* (d) Identity follows setCurrentUser: after setCurrentUser the prefix changes.
|
||||
@@ -17,7 +19,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, mock, beforeEach, afterEach, afterAll } from "bun:test";
|
||||
import { setAccessLog, enabled } from "../src/access-log";
|
||||
import { setAccessLog, enabled, shortNuri } from "../src/access-log";
|
||||
import { docCreate, sparqlUpdate, sparqlQuery } from "../src/docs";
|
||||
import {
|
||||
configure,
|
||||
@@ -47,7 +49,6 @@ function injectFake(debugAccessLog = false) {
|
||||
configure({ ng: ng as any, useShape: (() => {}) as any, debugAccessLog });
|
||||
configureStoreRegistry({
|
||||
getSession: async () => ({ sessionId: "sid-log", privateStoreId: "P" }),
|
||||
provisionRetry: { attempts: 1 },
|
||||
});
|
||||
return ng;
|
||||
}
|
||||
@@ -160,11 +161,12 @@ describe("access-log: ON via configure({ debugAccessLog: true })", () => {
|
||||
restore();
|
||||
}
|
||||
expect(lines.length).toBe(1);
|
||||
expect(lines[0]).toMatch(/\[alice\]/);
|
||||
expect(lines[0]).toMatch(/^\[alice\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
|
||||
expect(lines[0]).toMatch(/READ/);
|
||||
expect(lines[0]).toMatch(/did:ng:o:q/);
|
||||
expect(lines[0]).toContain(shortNuri("did:ng:o:q")); // NURI shortened
|
||||
expect(lines[0]).not.toContain("did:ng:o:"); // full prefix stripped
|
||||
expect(lines[0]).toMatch(/myLabel/);
|
||||
expect(lines[0]).toMatch(/→ 1 rows/); // row-count from the 1-row fake result
|
||||
expect(lines[0]).toMatch(/→ 1 triple-rows/); // triple-count from the 1-row fake result
|
||||
});
|
||||
|
||||
it("sparqlUpdate emits a WRITE line with identity, anchor nuri, and label", async () => {
|
||||
@@ -177,9 +179,9 @@ describe("access-log: ON via configure({ debugAccessLog: true })", () => {
|
||||
restore();
|
||||
}
|
||||
expect(lines.length).toBe(1);
|
||||
expect(lines[0]).toMatch(/\[alice\]/);
|
||||
expect(lines[0]).toMatch(/^\[alice\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
|
||||
expect(lines[0]).toMatch(/WRITE/);
|
||||
expect(lines[0]).toMatch(/did:ng:o:w/);
|
||||
expect(lines[0]).toContain(shortNuri("did:ng:o:w"));
|
||||
expect(lines[0]).toMatch(/writeLabel/);
|
||||
});
|
||||
|
||||
@@ -193,10 +195,10 @@ describe("access-log: ON via configure({ debugAccessLog: true })", () => {
|
||||
restore();
|
||||
}
|
||||
expect(lines.length).toBe(1);
|
||||
expect(lines[0]).toMatch(/\[alice\]/);
|
||||
expect(lines[0]).toMatch(/^\[alice\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
|
||||
expect(lines[0]).toMatch(/WRITE/);
|
||||
// The nuri is the value returned by ng.doc_create
|
||||
expect(lines[0]).toMatch(/did:ng:o:log-doc/);
|
||||
// The nuri is the value returned by ng.doc_create, shortened by shortNuri.
|
||||
expect(lines[0]).toContain(shortNuri("did:ng:o:log-doc"));
|
||||
});
|
||||
|
||||
it("enabled() returns true when set via setAccessLog", () => {
|
||||
@@ -219,9 +221,9 @@ describe("access-log: ON via env var NG_EVENTUALLY_ACCESS_LOG=1", () => {
|
||||
restore();
|
||||
}
|
||||
expect(lines.length).toBe(1);
|
||||
expect(lines[0]).toMatch(/\[bob\]/);
|
||||
expect(lines[0]).toMatch(/^\[bob\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
|
||||
expect(lines[0]).toMatch(/READ/);
|
||||
expect(lines[0]).toMatch(/did:ng:o:env-q/);
|
||||
expect(lines[0]).toContain(shortNuri("did:ng:o:env-q"));
|
||||
});
|
||||
|
||||
it("env var NG_EVENTUALLY_ACCESS_LOG=true also enables the log", async () => {
|
||||
@@ -236,7 +238,7 @@ describe("access-log: ON via env var NG_EVENTUALLY_ACCESS_LOG=1", () => {
|
||||
restore();
|
||||
}
|
||||
expect(lines.length).toBe(1);
|
||||
expect(lines[0]).toMatch(/\[charlie\]/);
|
||||
expect(lines[0]).toMatch(/^\[charlie\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
|
||||
expect(lines[0]).toMatch(/WRITE/);
|
||||
});
|
||||
|
||||
@@ -261,8 +263,8 @@ describe("access-log: identity follows setCurrentUser", () => {
|
||||
restore();
|
||||
}
|
||||
expect(lines.length).toBe(2);
|
||||
expect(lines[0]).toMatch(/\[first-user\]/);
|
||||
expect(lines[1]).toMatch(/\[second-user\]/);
|
||||
expect(lines[0]).toMatch(/^\[first-user\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
|
||||
expect(lines[1]).toMatch(/^\[second-user\]\[polyfill\] /);
|
||||
});
|
||||
|
||||
it("prefix is (none) when no identity is set", async () => {
|
||||
@@ -275,6 +277,6 @@ describe("access-log: identity follows setCurrentUser", () => {
|
||||
restore();
|
||||
}
|
||||
expect(lines.length).toBe(1);
|
||||
expect(lines[0]).toMatch(/\[\(none\)\]/);
|
||||
expect(lines[0]).toMatch(/^\[\(none\)\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
/**
|
||||
* anti-fork.test.ts — behavioral tests for the ANTI-FORK guard in ensureAccount /
|
||||
* resolveAccountReliably (src/store-registry.ts).
|
||||
* anti-fork.test.ts — behavioral tests for the reconnection fix in the
|
||||
* polyfill-era shim (src/store-registry.ts), redesigned around the
|
||||
* pointer → doc-shim indirection. Two groups, one file:
|
||||
*
|
||||
* The guard is: BEFORE reading the shim, open the private-store repo and await
|
||||
* the first `State` push (the deterministic sync barrier, CONTRACT 3). After the
|
||||
* barrier, 0 rows is DEFINITIVE (account genuinely absent) → provision exactly
|
||||
* once; rows present → reuse (NO-FORK). No retry loop, no polling.
|
||||
* (1) DETERMINISTIC RESOLUTION — a doc-shim whose account subject carries
|
||||
* DUPLICATE scope-doc values (fork residue: several `shim:docPublic`) must
|
||||
* resolve to the SAME canonical doc every time (lexicographically-smallest
|
||||
* NURI), so the session that WROTE an entity and a fresh page that RESOLVES
|
||||
* the doc never disagree. Robustness against PAST fork residue.
|
||||
*
|
||||
* With the unit fake `ng` (no `doc_subscribe`), `ensureRepoOpen` is a no-op
|
||||
* (getSyncState → "unknown") and the single shim read is immediate — no lag to
|
||||
* wait out, synchronous behaviour preserved.
|
||||
*
|
||||
* Timed-out barrier: if the barrier expires without a `State`, we refuse to
|
||||
* provision (throwing a clear error) rather than risk a fork.
|
||||
* (2) BARRIER-AUTHORITATIVE RECONNECT (the core fix) — a fresh page over a
|
||||
* persistent wallet resolves the SAME account through the doc-shim's
|
||||
* first-`State` BARRIER, with NO account-level retry. The account records
|
||||
* live in a subscribable doc-shim (`did:ng:o:...`) reached via a write-once
|
||||
* POINTER triple in the store-root; opening the doc-shim makes a cold read
|
||||
* authoritative, so a genuinely-present account is found on the first read
|
||||
* and never re-provisioned (no fork). This replaced the deleted
|
||||
* `resolveAccountReliably` / `provisionRetry` account retry.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
|
||||
import {
|
||||
ensureAccount,
|
||||
resolveAccount,
|
||||
loadShim,
|
||||
resetRegistryCache,
|
||||
} from "../src/store-registry";
|
||||
import type { RegistrySession } from "../src/store-registry";
|
||||
@@ -27,7 +33,7 @@ import {
|
||||
resetStoreRegistry,
|
||||
resetConfig,
|
||||
} from "../src/polyfill";
|
||||
import { resetOpenedRepos, _forceOpenedSyncState } from "../src/open-repo";
|
||||
import { resetOpenedRepos } from "../src/open-repo";
|
||||
|
||||
afterAll(() => {
|
||||
resetConfig();
|
||||
@@ -37,13 +43,17 @@ afterAll(() => {
|
||||
});
|
||||
|
||||
const SESSION: RegistrySession = { sessionId: "sid-af", privateStoreId: "PRIV-AF" };
|
||||
const ROOT = "did:ng:PRIV-AF";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake ng — simple in-memory store (no doc_subscribe → fake-ng no-op path)
|
||||
// Fake ng — in-memory quad store modelling the pointer → doc-shim indirection.
|
||||
//
|
||||
// Queries return data from the `quads` array immediately. No lag simulation:
|
||||
// the barrier mechanism (ensureRepoOpen) is a no-op in the fake-ng path, so
|
||||
// the single shim read after "the barrier" is already authoritative.
|
||||
// - The POINTER (`<shim:root> <shim:shimDoc> <docShim>`) lives in the store-root
|
||||
// graph (keyed by GRAPH <ROOT>).
|
||||
// - AccountRecords live in the doc-shim (anchored default graph, keyed by the
|
||||
// anchor arg = the doc-shim NURI).
|
||||
// - doc_subscribe pushes an initial `State` so ensureRepoOpen resolves at once
|
||||
// (the barrier).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Quad { g: string; s: string; p: string; o: string }
|
||||
@@ -61,14 +71,8 @@ function unescapeLiteral(s: string): string {
|
||||
return out;
|
||||
}
|
||||
|
||||
function makeFakeNg() {
|
||||
const quads: Quad[] = [];
|
||||
let docCounter = 0;
|
||||
let accountQueryCount = 0;
|
||||
|
||||
const doc_create = mock(async (..._a: unknown[]) => `did:ng:o:doc${++docCounter}`);
|
||||
|
||||
const sparql_update = mock(async (...a: unknown[]) => {
|
||||
function makeSparqlUpdate(quads: Quad[]) {
|
||||
return mock(async (...a: unknown[]) => {
|
||||
const query = a[1] as string;
|
||||
const anchor = a[2] as string | undefined;
|
||||
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
|
||||
@@ -95,163 +99,231 @@ function makeFakeNg() {
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
/** Build the account-SELECT bindings from the quads in ONE graph (the doc-shim),
|
||||
* grouped per subject. A subject with DUPLICATE scope docs yields a cross-product
|
||||
* of bindings — a corrupted shim. */
|
||||
function accountBindings(quads: Quad[], anchor: string | undefined, onlySubject: string | null) {
|
||||
const bySubject = new Map<string, { id: string; pub: string[]; prot: string[]; priv: string[] }>();
|
||||
for (const q of quads) {
|
||||
if (q.g !== anchor) continue;
|
||||
if (onlySubject !== null && q.s !== onlySubject) continue;
|
||||
const rec = bySubject.get(q.s) ?? { id: "", pub: [], prot: [], priv: [] };
|
||||
if (q.p === "urn:ng-eventually:shim:id") rec.id = q.o;
|
||||
if (q.p === "urn:ng-eventually:shim:docPublic") rec.pub.push(q.o);
|
||||
if (q.p === "urn:ng-eventually:shim:docProtected") rec.prot.push(q.o);
|
||||
if (q.p === "urn:ng-eventually:shim:docPrivate") rec.priv.push(q.o);
|
||||
bySubject.set(q.s, rec);
|
||||
}
|
||||
const bindings: Array<Record<string, { value: string }>> = [];
|
||||
for (const rec of bySubject.values()) {
|
||||
if (!rec.id) continue;
|
||||
const pubs = rec.pub.length ? rec.pub : [""];
|
||||
const prots = rec.prot.length ? rec.prot : [""];
|
||||
const privs = rec.priv.length ? rec.priv : [""];
|
||||
for (const pub of pubs)
|
||||
for (const prot of prots)
|
||||
for (const priv of privs)
|
||||
bindings.push({
|
||||
id: { value: rec.id },
|
||||
docPublic: { value: pub },
|
||||
docProtected: { value: prot },
|
||||
docPrivate: { value: priv },
|
||||
});
|
||||
}
|
||||
return bindings;
|
||||
}
|
||||
|
||||
/** Reactive fake ng modelling the pointer → doc-shim indirection. `doc_subscribe`
|
||||
* pushes a first `State` so the doc-shim barrier resolves synchronously. */
|
||||
function makeFakeNg() {
|
||||
const quads: Quad[] = [];
|
||||
let docCounter = 0;
|
||||
// Count account SELECTs per anchor graph. The AUTHORITATIVE account read is anchored
|
||||
// to the doc-shim (a did:ng:o: NURI). Counting per-anchor lets a test assert "exactly
|
||||
// one doc-shim account read" (no account RETRY).
|
||||
const accountReadsByAnchor = new Map<string, number>();
|
||||
const doc_create = mock(async (..._a: unknown[]) => `did:ng:o:doc${++docCounter}`);
|
||||
const sparql_update = makeSparqlUpdate(quads);
|
||||
const sparql_query = mock(async (...a: unknown[]) => {
|
||||
const query = a[1] as string;
|
||||
const anchor = a[3] as string | undefined;
|
||||
|
||||
if (query.includes("<urn:ng-eventually:shim:id>")) {
|
||||
// Account SELECT (resolveAccount / loadShim)
|
||||
accountQueryCount++;
|
||||
|
||||
const subjM = query.match(/GRAPH <[^>]+>\s*\{\s*<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
|
||||
const onlySubject = subjM ? subjM[1]! : null;
|
||||
const bySubject = new Map<string, Record<string, string>>();
|
||||
for (const q of quads) {
|
||||
if (q.g !== anchor) continue;
|
||||
if (onlySubject !== null && q.s !== onlySubject) continue;
|
||||
const rec = bySubject.get(q.s) ?? {};
|
||||
if (q.p === "urn:ng-eventually:shim:id") rec.id = q.o;
|
||||
if (q.p === "urn:ng-eventually:shim:docPublic") rec.docPublic = q.o;
|
||||
if (q.p === "urn:ng-eventually:shim:docProtected") rec.docProtected = q.o;
|
||||
if (q.p === "urn:ng-eventually:shim:docPrivate") rec.docPrivate = q.o;
|
||||
bySubject.set(q.s, rec);
|
||||
}
|
||||
const bindings = [...bySubject.values()]
|
||||
.filter((r) => r.id)
|
||||
.map((r) => ({
|
||||
id: { value: r.id! },
|
||||
docPublic: { value: r.docPublic ?? "" },
|
||||
docProtected: { value: r.docProtected ?? "" },
|
||||
docPrivate: { value: r.docPrivate ?? "" },
|
||||
}));
|
||||
// Pointer SELECT (store-root).
|
||||
if (query.includes("<urn:ng-eventually:shim:shimDoc>")) {
|
||||
const bindings = quads
|
||||
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:shimDoc")
|
||||
.map((q) => ({ shimDoc: { value: q.o } }));
|
||||
return { results: { bindings } };
|
||||
}
|
||||
|
||||
// Entity-index SELECT
|
||||
// Account SELECT — anchored to the doc-shim (the authoritative read).
|
||||
if (query.includes("<urn:ng-eventually:shim:id>")) {
|
||||
accountReadsByAnchor.set(anchor ?? "", (accountReadsByAnchor.get(anchor ?? "") ?? 0) + 1);
|
||||
const subjM = query.match(/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
|
||||
return { results: { bindings: accountBindings(quads, anchor, subjM ? subjM[1]! : null) } };
|
||||
}
|
||||
const bindings = quads
|
||||
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:contains")
|
||||
.map((q) => ({ e: { value: q.o } }));
|
||||
return { results: { bindings } };
|
||||
});
|
||||
|
||||
// Push a `State` on subscribe (the sync barrier) so ensureRepoOpen resolves at once.
|
||||
const doc_subscribe = mock(async (_repo: unknown, _sid: unknown, cb: Function) => {
|
||||
if (typeof cb === "function") cb({ V0: { State: {} } });
|
||||
return () => {};
|
||||
});
|
||||
return {
|
||||
doc_create,
|
||||
sparql_update,
|
||||
sparql_query,
|
||||
doc_create, sparql_update, sparql_query, doc_subscribe,
|
||||
_quads: quads,
|
||||
getAccountQueryCount: () => accountQueryCount,
|
||||
// Total account reads across all anchors.
|
||||
getAccountQueryCount: () => [...accountReadsByAnchor.values()].reduce((a, b) => a + b, 0),
|
||||
// Account reads anchored to a did:ng:o: doc-shim.
|
||||
getDocShimAccountReads: () =>
|
||||
[...accountReadsByAnchor.entries()]
|
||||
.filter(([g]) => g.startsWith("did:ng:o:"))
|
||||
.reduce((a, [, n]) => a + n, 0),
|
||||
};
|
||||
}
|
||||
|
||||
function inject(fakeNg: ReturnType<typeof makeFakeNg>) {
|
||||
// No doc_subscribe → ensureRepoOpen is a no-op (fake-ng path).
|
||||
function inject(
|
||||
fakeNg: unknown,
|
||||
pointerGuard?: { attempts?: number; baseMs?: number; maxStepMs?: number },
|
||||
) {
|
||||
configure({ ng: fakeNg as any, useShape: (() => {}) as any });
|
||||
configureStoreRegistry({
|
||||
getSession: async () => SESSION,
|
||||
normalizeId: (u) => u.trim().toLowerCase(),
|
||||
// provisionRetry is now unused by the barrier mechanism; kept for API compat.
|
||||
provisionRetry: { attempts: 1 },
|
||||
pointerGuard,
|
||||
});
|
||||
resetRegistryCache();
|
||||
resetOpenedRepos();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// (1) Deterministic resolution over fork residue (in the doc-shim)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("anti-fork: barrier-first resolveAccountReliably / ensureAccount", () => {
|
||||
beforeEach(() => {
|
||||
resetRegistryCache();
|
||||
resetOpenedRepos();
|
||||
});
|
||||
describe("deterministic resolution over a doc-shim corrupted by fork residue", () => {
|
||||
beforeEach(() => { resetRegistryCache(); resetOpenedRepos(); });
|
||||
|
||||
it("(a) NO-FORK: account already in shim → reused, 0 new doc_create", async () => {
|
||||
// Provision an account in an initial "session" (quads are written).
|
||||
it("(1a) a subject with MULTIPLE docPublic values always resolves the SAME canonical (lexicographically-smallest)", async () => {
|
||||
const fakeNg = makeFakeNg();
|
||||
inject(fakeNg);
|
||||
const first = await ensureAccount("LauraBarrier");
|
||||
expect(fakeNg.doc_create).toHaveBeenCalledTimes(3);
|
||||
const docShim = "did:ng:o:shimdoc";
|
||||
// Seed the pointer (store-root → doc-shim) and the corrupted record IN the doc-shim.
|
||||
fakeNg._quads.push({ g: ROOT, s: "urn:ng-eventually:shim:root", p: "urn:ng-eventually:shim:shimDoc", o: docShim });
|
||||
const subj = "urn:ng-eventually:shim:account:dupuser";
|
||||
const dupPublics = [
|
||||
"did:ng:o:pub-m", "did:ng:o:pub-a", "did:ng:o:pub-z", "did:ng:o:pub-c", "did:ng:o:pub-a",
|
||||
];
|
||||
fakeNg._quads.push({ g: docShim, s: subj, p: "urn:ng-eventually:shim:id", o: "dupuser" });
|
||||
for (const p of dupPublics)
|
||||
fakeNg._quads.push({ g: docShim, s: subj, p: "urn:ng-eventually:shim:docPublic", o: p });
|
||||
fakeNg._quads.push({ g: docShim, s: subj, p: "urn:ng-eventually:shim:docProtected", o: "did:ng:o:prot-1" });
|
||||
fakeNg._quads.push({ g: docShim, s: subj, p: "urn:ng-eventually:shim:docPrivate", o: "did:ng:o:priv-1" });
|
||||
|
||||
// Simulate a fresh session: clear caches but keep quads intact (same fakeNg).
|
||||
// In the barrier model the single post-barrier read finds the account immediately.
|
||||
const r1 = await resolveAccount("dupuser");
|
||||
resetRegistryCache();
|
||||
const r2 = await resolveAccount("dupuser");
|
||||
const viaShim = (await loadShim()).get("dupuser");
|
||||
|
||||
// Canonical = lexicographically smallest → "did:ng:o:pub-a".
|
||||
expect(r1?.docPublic).toBe("did:ng:o:pub-a");
|
||||
expect(r2?.docPublic).toBe(r1?.docPublic);
|
||||
expect(viaShim?.docPublic).toBe(r1?.docPublic);
|
||||
expect(viaShim?.docProtected).toBe("did:ng:o:prot-1");
|
||||
expect(viaShim?.docPrivate).toBe("did:ng:o:priv-1");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (2) Barrier-authoritative reconnect — the core fix (no account retry)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("reconnect resolves the SAME account through the doc-shim barrier (no fork, no account retry)", () => {
|
||||
beforeEach(() => { resetRegistryCache(); resetOpenedRepos(); });
|
||||
|
||||
it("(2a) NO-FORK: account already in the doc-shim → reused, 0 new scope docs", async () => {
|
||||
const fakeNg = makeFakeNg();
|
||||
inject(fakeNg);
|
||||
// First login: provisions the account (1 doc-shim + 3 scope docs).
|
||||
const first = await ensureAccount("LauraBarrier");
|
||||
expect(fakeNg.doc_create).toHaveBeenCalledTimes(4);
|
||||
|
||||
// Fresh page over the SAME persistent quads: reset all in-memory caches, keep the
|
||||
// quads. Reconnect must find the SAME account via the doc-shim barrier, NO new docs.
|
||||
resetRegistryCache();
|
||||
resetOpenedRepos();
|
||||
|
||||
const second = await ensureAccount("LauraBarrier");
|
||||
|
||||
// ANTI-FORK: same scope docs returned, no new provisioning
|
||||
expect(second.docPublic).toBe(first.docPublic);
|
||||
expect(second.docProtected).toBe(first.docProtected);
|
||||
expect(second.docPrivate).toBe(first.docPrivate);
|
||||
expect(fakeNg.doc_create).toHaveBeenCalledTimes(3); // still exactly 3, not 6
|
||||
// Still 4 — no doc-shim re-created (pointer reused), no scope docs re-created.
|
||||
expect(fakeNg.doc_create).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("(b) genuinely new account (fake returns 0 rows) → provisioned exactly once (3 doc_create)", async () => {
|
||||
const fakeNg = makeFakeNg(); // empty quads → 0 rows on any account query
|
||||
inject(fakeNg);
|
||||
it("(2b) barrier-authoritative: a fresh session finds the persisted account on the FIRST read — single doc-shim read, NO retry", async () => {
|
||||
// Seed session 1; capture the account NURIs + the persistent quads.
|
||||
const seed = makeFakeNg();
|
||||
inject(seed);
|
||||
const orig = await ensureAccount("BarrierUser");
|
||||
expect(seed.doc_create).toHaveBeenCalledTimes(4);
|
||||
|
||||
// Fresh reactive session over the SAME persistent quads. The doc-shim pushes a
|
||||
// `State` on subscribe → resolveShimDoc opens the barrier → the account read is
|
||||
// authoritative on the FIRST attempt. Give a MULTI-attempt pointer guard to prove
|
||||
// it is NOT used for the account.
|
||||
const reconnect = makeFakeNg();
|
||||
reconnect._quads.push(...seed._quads);
|
||||
inject(reconnect, { attempts: 8, baseMs: 1, maxStepMs: 2 });
|
||||
|
||||
const resolved = await ensureAccount("BarrierUser");
|
||||
|
||||
expect(resolved.docPublic).toBe(orig.docPublic);
|
||||
expect(resolved.docProtected).toBe(orig.docProtected);
|
||||
expect(resolved.docPrivate).toBe(orig.docPrivate);
|
||||
expect(reconnect.doc_create).toHaveBeenCalledTimes(0); // no new docs → no fork
|
||||
// Barrier-authoritative: found on the FIRST doc-shim read.
|
||||
expect(reconnect.getDocShimAccountReads()).toBe(1);
|
||||
});
|
||||
|
||||
it("(2c) GENUINELY NEW: a cold doc-shim reads 0 → provisioned exactly once, no retry", async () => {
|
||||
// Pointer + doc-shim exist but the doc-shim holds NO record for this account.
|
||||
const fakeNg = makeFakeNg();
|
||||
inject(fakeNg, { attempts: 5, baseMs: 1, maxStepMs: 2 });
|
||||
const docShim = "did:ng:o:preexisting-shim";
|
||||
fakeNg._quads.push({ g: ROOT, s: "urn:ng-eventually:shim:root", p: "urn:ng-eventually:shim:shimDoc", o: docShim });
|
||||
|
||||
const rec = await ensureAccount("BrandNewUser");
|
||||
|
||||
// Exactly 3 doc_create calls (1 set of scope docs, no fork)
|
||||
// Provisioned exactly ONE set of 3 scope docs (the doc-shim already existed).
|
||||
expect(fakeNg.doc_create).toHaveBeenCalledTimes(3);
|
||||
expect(rec.docPublic).toMatch(/^did:ng:o:doc/);
|
||||
expect(rec.docProtected).not.toBe(rec.docPublic);
|
||||
expect(rec.docPrivate).not.toBe(rec.docProtected);
|
||||
// Single read — no retry loop
|
||||
expect(fakeNg.getAccountQueryCount()).toBe(1);
|
||||
// Barrier-authoritative: exactly ONE doc-shim account read (the 0 is definitive).
|
||||
expect(fakeNg.getDocShimAccountReads()).toBe(1);
|
||||
});
|
||||
|
||||
it("(c) idempotence within a session: calling ensureAccount twice never creates 2 sets", async () => {
|
||||
it("(2d) default budget (unset pointer guard): genuinely-new account → single doc-shim account read", async () => {
|
||||
const fakeNg = makeFakeNg();
|
||||
inject(fakeNg); // pointerGuard unset → attempts:1 (synchronous default)
|
||||
|
||||
const rec = await ensureAccount("SyncUser");
|
||||
|
||||
// 1 doc-shim + 3 scope docs.
|
||||
expect(fakeNg.doc_create).toHaveBeenCalledTimes(4);
|
||||
expect(fakeNg.getDocShimAccountReads()).toBe(1);
|
||||
expect(rec.docPublic).toMatch(/^did:ng:o:doc/);
|
||||
});
|
||||
|
||||
it("(2e) idempotence within a session: ensureAccount twice never creates 2 sets", async () => {
|
||||
const fakeNg = makeFakeNg();
|
||||
inject(fakeNg);
|
||||
|
||||
const a = await ensureAccount("SameUser");
|
||||
const b = await ensureAccount("SameUser");
|
||||
|
||||
expect(b).toEqual(a);
|
||||
// Must still be exactly 3 (not 6)
|
||||
expect(fakeNg.doc_create).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("(d) fake-ng no-op barrier path: single shim read, no doc_subscribe calls", async () => {
|
||||
// The fake ng has no doc_subscribe → ensureRepoOpen is a no-op (getSyncState → "unknown").
|
||||
// resolveAccountReliably must proceed to the single read without waiting or throwing.
|
||||
const fakeNg = makeFakeNg(); // no doc_subscribe
|
||||
inject(fakeNg);
|
||||
|
||||
// Provision once, then verify a second resolve (fresh cache) finds it immediately.
|
||||
const first = await ensureAccount("FakeNgUser");
|
||||
resetRegistryCache();
|
||||
resetOpenedRepos();
|
||||
|
||||
const second = await ensureAccount("FakeNgUser");
|
||||
|
||||
// Same docs reused (no fork), exactly 3 total doc_create across both calls
|
||||
expect(second.docPublic).toBe(first.docPublic);
|
||||
expect(fakeNg.doc_create).toHaveBeenCalledTimes(3);
|
||||
// The account query was called exactly twice (once per ensureAccount, no retries)
|
||||
expect(fakeNg.getAccountQueryCount()).toBe(2);
|
||||
});
|
||||
|
||||
it("(e) timed-out barrier: resolveAccountReliably throws rather than provisioning", async () => {
|
||||
// Simulate: the private store nuri is already in the `opened` set but with
|
||||
// sync state "timed-out" (forced via _forceOpenedSyncState so the test
|
||||
// does not have to wait 8s for the real OPEN_TIMEOUT_MS to fire).
|
||||
const fakeNg = makeFakeNg(); // empty quads → 0 rows
|
||||
inject(fakeNg);
|
||||
|
||||
// Force the private-store nuri ("did:ng:PRIV-AF") into timed-out state.
|
||||
// resolveAccountReliably calls anchorNuri() → `did:ng:${privateStoreId}`.
|
||||
_forceOpenedSyncState("did:ng:PRIV-AF", "timed-out");
|
||||
|
||||
// ensureAccount must throw (conservative anti-fork guard: do not provision blind)
|
||||
await expect(ensureAccount("TimedOutUser")).rejects.toThrow(
|
||||
/sync barrier timed out/,
|
||||
);
|
||||
|
||||
// Must NOT have created any scope docs (refusing to provision)
|
||||
expect(fakeNg.doc_create).toHaveBeenCalledTimes(0);
|
||||
expect(fakeNg.doc_create).toHaveBeenCalledTimes(4); // 1 doc-shim + 3 scope docs
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* cold-start-anchor.test.ts — the shim ANCHOR (private-store-root) must be OPENED
|
||||
* before the registry reads/writes it, or a cold anchor throws `RepoNotFound`.
|
||||
*
|
||||
* ── The gap this pins ──────────────────────────────────────────────────────
|
||||
* The shim lives in the private-store-root graph (`did:ng:${privateStoreId}`, the
|
||||
* "anchor"). Unlike a per-entity doc — whose anchored read on an unopened repo
|
||||
* SILENTLY returns 0 rows — the private/store target resolves through the verifier's
|
||||
* `resolve_target_for_sparql`, which HARD-errors `RepoNotFound` when the repo is not
|
||||
* in `self.repos` (verified in nextgraph-rs `request_processor.rs`). On a wallet whose
|
||||
* anchor repo is not yet loaded, both the shim READ (`resolveAccount`/`loadShim`) and
|
||||
* the provision WRITE (`ensureAccount`) throw — so the account never provisions.
|
||||
*
|
||||
* The heal: `resolveAccount`/`loadShim`/`ensureAccount` call `ensureRepoOpen(anchor)`
|
||||
* (open-repo.ts, via `doc_subscribe` + first-`State` barrier) before touching the
|
||||
* shim — the same open-before-read guard `readScopeIndex` already applies to its
|
||||
* index doc. This suite models a fake `ng` where the anchor throws `RepoNotFound`
|
||||
* UNTIL it has been `doc_subscribe`-d, and asserts the registry provisions cleanly.
|
||||
*
|
||||
* RED without the heal (ensureAccount would throw on the cold anchor); GREEN with it.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, mock, afterAll, beforeEach } from "bun:test";
|
||||
import { ensureAccount, resolveWriteGraph, resetRegistryCache } from "../src/store-registry";
|
||||
import { resetOpenedRepos } from "../src/open-repo";
|
||||
import {
|
||||
configure,
|
||||
configureStoreRegistry,
|
||||
resetStoreRegistry,
|
||||
resetConfig,
|
||||
} from "../src/polyfill";
|
||||
|
||||
const SESSION = { sessionId: "sid-cold", privateStoreId: "PRIV-COLD" };
|
||||
const ANCHOR = `did:ng:${SESSION.privateStoreId}`;
|
||||
|
||||
afterAll(() => {
|
||||
resetConfig();
|
||||
resetStoreRegistry();
|
||||
resetRegistryCache();
|
||||
resetOpenedRepos();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetRegistryCache();
|
||||
resetOpenedRepos();
|
||||
});
|
||||
|
||||
interface Quad { g: string; s: string; p: string; o: string }
|
||||
|
||||
function unescapeLiteral(s: string): string {
|
||||
let out = "";
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
if (s[i] === "\\" && i + 1 < s.length) {
|
||||
const next = s[++i];
|
||||
out += next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next!;
|
||||
} else out += s[i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A fake `ng` whose ANCHOR repo behaves like the real private-store target:
|
||||
* `sparql_query`/`sparql_update` anchored to it THROW `RepoNotFound` until the
|
||||
* anchor has been `doc_subscribe`-d (i.e. opened into `self.repos`). Any OTHER
|
||||
* anchor (per-entity docs) behaves normally. `doc_subscribe` fires the first
|
||||
* `State` so `ensureRepoOpen` crosses the barrier.
|
||||
*/
|
||||
function makeColdAnchorNg() {
|
||||
const quads: Quad[] = [];
|
||||
let docCounter = 0;
|
||||
const opened = new Set<string>();
|
||||
let anchorSubscribes = 0;
|
||||
|
||||
const doc_create = mock(async () => `did:ng:o:doc${++docCounter}`);
|
||||
|
||||
const doc_subscribe = mock(async (nuri: string, _sid: string, cb: (r: unknown) => void) => {
|
||||
if (nuri === ANCHOR) anchorSubscribes += 1;
|
||||
opened.add(nuri);
|
||||
setTimeout(() => cb({ V0: { State: {} } }), 0);
|
||||
return () => {};
|
||||
});
|
||||
|
||||
const sparql_update = mock(async (_sid: string, query: string, anchor?: string) => {
|
||||
if (anchor === ANCHOR && !opened.has(ANCHOR)) throw new Error("RepoNotFound");
|
||||
// TWO shapes: the POINTER write uses `GRAPH <root>` (keyed by IRI); the account
|
||||
// record write into the doc-shim has NO explicit GRAPH (keyed by the anchor arg).
|
||||
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
|
||||
let g: string;
|
||||
let body: string;
|
||||
if (gm) {
|
||||
g = gm[1]!;
|
||||
body = gm[2]!;
|
||||
} else {
|
||||
if (!anchor) return undefined;
|
||||
g = anchor;
|
||||
body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
|
||||
}
|
||||
const sm = body.match(/<([^>]+)>/);
|
||||
if (!sm) return undefined;
|
||||
const s = sm[1]!;
|
||||
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
|
||||
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = pairRe.exec(after)) !== null) {
|
||||
const p = m[1] ?? "urn:ng-eventually:shim:Account";
|
||||
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
|
||||
quads.push({ g, s, p, o });
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const sparql_query = mock(async (_sid: string, query: string, _base: unknown, anchor?: string) => {
|
||||
if (anchor === ANCHOR && !opened.has(ANCHOR)) throw new Error("RepoNotFound");
|
||||
// Pointer SELECT (store-root -> doc-shim).
|
||||
if (query.includes("<urn:ng-eventually:shim:shimDoc>")) {
|
||||
const bindings = quads
|
||||
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:shimDoc")
|
||||
.map((q) => ({ shimDoc: { value: q.o } }));
|
||||
return { results: { bindings } };
|
||||
}
|
||||
const subjM = query.match(
|
||||
/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/,
|
||||
);
|
||||
const onlySubject = subjM ? subjM[1]! : null;
|
||||
const bySubject = new Map<string, Record<string, string>>();
|
||||
for (const q of quads) {
|
||||
if (q.g !== anchor) continue;
|
||||
if (onlySubject !== null && q.s !== onlySubject) continue;
|
||||
const rec = bySubject.get(q.s) ?? {};
|
||||
if (q.p === "urn:ng-eventually:shim:id") rec.id = q.o;
|
||||
if (q.p === "urn:ng-eventually:shim:docPublic") rec.docPublic = q.o;
|
||||
if (q.p === "urn:ng-eventually:shim:docProtected") rec.docProtected = q.o;
|
||||
if (q.p === "urn:ng-eventually:shim:docPrivate") rec.docPrivate = q.o;
|
||||
bySubject.set(q.s, rec);
|
||||
}
|
||||
const bindings = [...bySubject.values()]
|
||||
.filter((r) => r.id)
|
||||
.map((r) => ({
|
||||
id: { value: r.id! },
|
||||
docPublic: { value: r.docPublic ?? "" },
|
||||
docProtected: { value: r.docProtected ?? "" },
|
||||
docPrivate: { value: r.docPrivate ?? "" },
|
||||
}));
|
||||
return { results: { bindings } };
|
||||
});
|
||||
|
||||
return {
|
||||
doc_create, doc_subscribe, sparql_update, sparql_query,
|
||||
_quads: quads,
|
||||
anchorSubscribeCount: () => anchorSubscribes,
|
||||
};
|
||||
}
|
||||
|
||||
function inject(ng: ReturnType<typeof makeColdAnchorNg>) {
|
||||
configure({ ng: ng as any, useShape: (() => {}) as any });
|
||||
configureStoreRegistry({
|
||||
getSession: async () => SESSION,
|
||||
normalizeId: (u: string) => u.trim().replace(/^@+/, "").toLowerCase(),
|
||||
});
|
||||
resetRegistryCache();
|
||||
resetOpenedRepos();
|
||||
}
|
||||
|
||||
describe("cold-start anchor heal", () => {
|
||||
it("ensureAccount provisions over a COLD anchor (RepoNotFound-until-opened) without throwing", async () => {
|
||||
const ng = makeColdAnchorNg();
|
||||
inject(ng);
|
||||
|
||||
// Without the open-before-shim heal, the read AND the provision write would both
|
||||
// throw RepoNotFound on the cold anchor and the account would never persist.
|
||||
const rec = await ensureAccount("@cold-alice");
|
||||
expect(rec.docPublic).toBeTruthy();
|
||||
expect(rec.docProtected).toBeTruthy();
|
||||
expect(rec.docPrivate).toBeTruthy();
|
||||
|
||||
// The anchor repo was actually opened (doc_subscribe-d) before the shim op.
|
||||
expect(ng.anchorSubscribeCount()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("the provisioned account re-resolves from the shim (real persistence, no RepoNotFound)", async () => {
|
||||
const ng = makeColdAnchorNg();
|
||||
inject(ng);
|
||||
|
||||
const first = await ensureAccount("@cold-bob");
|
||||
// Fresh cache → a real anchored re-read of the shim (anchor already opened → OK).
|
||||
resetRegistryCache();
|
||||
const again = await ensureAccount("@cold-bob");
|
||||
expect(again.docPublic).toBe(first.docPublic);
|
||||
expect(again.docProtected).toBe(first.docProtected);
|
||||
expect(again.docPrivate).toBe(first.docPrivate);
|
||||
});
|
||||
|
||||
it("resolveWriteGraph (scope resolver) works over a cold anchor", async () => {
|
||||
const ng = makeColdAnchorNg();
|
||||
inject(ng);
|
||||
const g = await resolveWriteGraph("@cold-carol", "protected");
|
||||
expect(g).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -119,11 +119,18 @@ function makeFakeNg() {
|
||||
const sparql_query = mock(async (...a: unknown[]) => {
|
||||
const query = a[1] as string;
|
||||
const anchor = a[3] as string | undefined;
|
||||
// Shim account SELECT. Two shapes: the full scan (`?acc a <Account>`) and
|
||||
// the TARGETED bounded resolve (`<subj> a <Account>`), which binds one
|
||||
// subject — honour that subject filter so the bounded query is O(1)/exact.
|
||||
// Pointer SELECT: `<shim:root> <shim:shimDoc> ?shimDoc` in the store-root graph.
|
||||
if (query.includes(`<${SHIM}:shimDoc>`)) {
|
||||
const bindings = quads
|
||||
.filter((q) => q.g === anchor && q.p === `${SHIM}:shimDoc`)
|
||||
.map((q) => ({ shimDoc: { value: q.o } }));
|
||||
return { results: { bindings } };
|
||||
}
|
||||
// Shim account SELECT (anchored to the doc-shim, no GRAPH wrapper). Two shapes:
|
||||
// the full scan (`?acc a <Account>`) and the TARGETED bounded resolve (`<subj> a
|
||||
// <Account>`) — honour that subject filter so the bounded query is O(1)/exact.
|
||||
if (query.includes(`<${SHIM}:id>`)) {
|
||||
const subjM = query.match(new RegExp(`GRAPH <[^>]+>\\s*\\{\\s*<([^>]+)>\\s+a\\s+<${SHIM}:Account>`));
|
||||
const subjM = query.match(new RegExp(`<([^>]+)>\\s+a\\s+<${SHIM}:Account>`));
|
||||
const onlySubject = subjM ? subjM[1]! : null;
|
||||
const bySubject = new Map<string, Record<string, string>>();
|
||||
for (const q of quads) {
|
||||
@@ -188,8 +195,6 @@ function inject() {
|
||||
configureStoreRegistry({
|
||||
getSession: async () => SESSION,
|
||||
normalizeId: (u) => u.trim().replace(/^@+/, "").toLowerCase(),
|
||||
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
||||
provisionRetry: { attempts: 1 },
|
||||
});
|
||||
resetRegistryCache();
|
||||
setCurrentUser(null);
|
||||
@@ -203,8 +208,8 @@ beforeEach(() => {
|
||||
|
||||
test("submitToIndex creates the @index special account on first sight (3 docs)", async () => {
|
||||
await submitToIndex({ nuri: "did:ng:o:event1", title: "Concert" });
|
||||
// ensureAccount('@index') created its 3 scope docs.
|
||||
expect(fake.doc_create).toHaveBeenCalledTimes(3);
|
||||
// ensureAccount('@index') created its 3 scope docs + 1 doc-shim (first login).
|
||||
expect(fake.doc_create).toHaveBeenCalledTimes(4);
|
||||
// The deposit landed in the @index public document (its inbox).
|
||||
const depositCall = fake.sparql_update.mock.calls.find((c) =>
|
||||
(c[1] as string).includes(`${INBOX}:Deposit`),
|
||||
|
||||
@@ -153,7 +153,7 @@ function inject() {
|
||||
const ng = makeFakeNg();
|
||||
configure({ ng: ng as any, useShape: (() => {}) as any });
|
||||
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
||||
configureStoreRegistry({ getSession: async () => SESSION, provisionRetry: { attempts: 1 } });
|
||||
configureStoreRegistry({ getSession: async () => SESSION });
|
||||
setCurrentUser(null);
|
||||
return ng;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ function inject() {
|
||||
};
|
||||
configure({ ng: ng as any, useShape: (() => {}) as any });
|
||||
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
||||
configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim(), provisionRetry: { attempts: 1 } });
|
||||
configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim() });
|
||||
resetRegistryCache();
|
||||
resetCaps();
|
||||
setCurrentUser(null);
|
||||
|
||||
@@ -86,7 +86,6 @@ function inject(ng: ReturnType<typeof makeFakeNgWithSubscribe>) {
|
||||
configureStoreRegistry({
|
||||
getSession: async () => SESSION,
|
||||
normalizeId: (u: string) => u,
|
||||
provisionRetry: { attempts: 1 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -151,7 +150,6 @@ describe("ensureRepoOpen", () => {
|
||||
configureStoreRegistry({
|
||||
getSession: async () => SESSION,
|
||||
normalizeId: (u: string) => u,
|
||||
provisionRetry: { attempts: 1 },
|
||||
});
|
||||
|
||||
// Must not throw; nuri is added to opened Set (guard skips subscribe)
|
||||
|
||||
@@ -35,8 +35,6 @@ function inject(triplesByDoc: Record<string, Array<[string, string]>>) {
|
||||
configureStoreRegistry({
|
||||
getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }),
|
||||
normalizeId: (u: string) => u,
|
||||
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
||||
provisionRetry: { attempts: 1 },
|
||||
});
|
||||
return ng;
|
||||
}
|
||||
@@ -99,8 +97,6 @@ test("a doc that fails to read is skipped, not aborting the batch", async () =>
|
||||
configureStoreRegistry({
|
||||
getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }),
|
||||
normalizeId: (u: string) => u,
|
||||
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
||||
provisionRetry: { attempts: 1 },
|
||||
});
|
||||
|
||||
const subjects = await readUnion(["did:ng:o:ok", "did:ng:o:bad"]);
|
||||
|
||||
@@ -112,12 +112,18 @@ function makeFakeNg() {
|
||||
const sparql_query = mock(async (...a: unknown[]) => {
|
||||
const query = a[1] as string;
|
||||
const anchor = a[3] as string | undefined;
|
||||
// Pointer SELECT: `<shim:root> <shim:shimDoc> ?shimDoc` in the store-root graph.
|
||||
if (query.includes("<urn:ng-eventually:shim:shimDoc>")) {
|
||||
const bindings = quads
|
||||
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:shimDoc")
|
||||
.map((q) => ({ shimDoc: { value: q.o } }));
|
||||
return { results: { bindings } };
|
||||
}
|
||||
if (query.includes("<urn:ng-eventually:shim:id>")) {
|
||||
// Account SELECT, scoped to the anchor graph. Two shapes: the full scan
|
||||
// (`?acc a <Account>`) and the TARGETED bounded resolve (`<subj> a
|
||||
// <Account>`) which binds one subject — honour that subject so the bounded
|
||||
// query returns exactly that account (or nothing).
|
||||
const subjM = query.match(/GRAPH <[^>]+>\s*\{\s*<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
|
||||
// Account SELECT, anchored to the doc-shim's default graph (records live in the
|
||||
// doc-shim now, no GRAPH wrapper). Two shapes: the full scan (`?acc a <Account>`)
|
||||
// and the TARGETED bounded resolve (`<subj> a <Account>`) — honour the subject.
|
||||
const subjM = query.match(/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
|
||||
const onlySubject = subjM ? subjM[1]! : null;
|
||||
const bySubject = new Map<string, Record<string, string>>();
|
||||
for (const q of quads) {
|
||||
@@ -158,8 +164,6 @@ function inject() {
|
||||
configureStoreRegistry({
|
||||
getSession: async () => SESSION,
|
||||
normalizeId: (u) => u.trim().replace(/^@+/, "").toLowerCase(),
|
||||
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
||||
provisionRetry: { attempts: 1 },
|
||||
});
|
||||
resetRegistryCache();
|
||||
return ng;
|
||||
@@ -170,22 +174,53 @@ beforeEach(() => {
|
||||
fake = inject();
|
||||
});
|
||||
|
||||
test("ensureAccount creates 3 scope docs and persists them to the shim", async () => {
|
||||
test("ensureAccount creates 3 scope docs and persists them to the doc-shim", async () => {
|
||||
const rec = await ensureAccount("Alice");
|
||||
expect(rec.id).toBe("Alice");
|
||||
expect(fake.doc_create).toHaveBeenCalledTimes(3);
|
||||
// 4 doc_create: 1 doc-shim (first login, resolveShimDoc) + 3 scope docs.
|
||||
expect(fake.doc_create).toHaveBeenCalledTimes(4);
|
||||
expect(rec.docPublic).toMatch(/^did:ng:o:doc/);
|
||||
expect(rec.docProtected).not.toBe(rec.docPublic);
|
||||
expect(rec.docPrivate).not.toBe(rec.docProtected);
|
||||
// Persisted into the shim anchor graph (did:ng:PRIV).
|
||||
expect(fake.sparql_update.mock.calls[0]![2]).toBe("did:ng:PRIV");
|
||||
// The pointer (store-root -> doc-shim) was written into the store-root graph.
|
||||
const pointerWrite = fake.sparql_update.mock.calls.find(
|
||||
(c) => (c[1] as string).includes("<urn:ng-eventually:shim:shimDoc>"),
|
||||
);
|
||||
expect(pointerWrite?.[2]).toBe("did:ng:PRIV");
|
||||
// The account record was persisted into the doc-shim (a did:ng:o: repo), not the root.
|
||||
const recordWrite = fake.sparql_update.mock.calls.find(
|
||||
(c) => (c[1] as string).includes("<urn:ng-eventually:shim:docPublic>"),
|
||||
);
|
||||
expect(recordWrite?.[2]).toMatch(/^did:ng:o:doc/);
|
||||
});
|
||||
|
||||
test("ensureAccount is idempotent (case/@-insensitive key), no extra docs", async () => {
|
||||
const a = await ensureAccount("Alice");
|
||||
const b = await ensureAccount("@alice");
|
||||
expect(b).toEqual(a);
|
||||
expect(fake.doc_create).toHaveBeenCalledTimes(3); // not 6
|
||||
expect(fake.doc_create).toHaveBeenCalledTimes(4); // 1 doc-shim + 3 scope docs, not 7
|
||||
});
|
||||
|
||||
test("ensureAccount de-dupes CONCURRENT provisions (anti-fork): one account, 3 docs", async () => {
|
||||
// The reconnection FORK: several callers (watchShape public+protected, the
|
||||
// container subs, the owned-events effect) hit ensureAccount(SAME id) BEFORE
|
||||
// the shim has synced, so each reads 0 rows and independently provisions a new
|
||||
// set of scope docs — N forks, N×3 docs, duplicate docPublic/docProtected in the
|
||||
// shim → a fresh reader picks a different canonical doc than the writer wrote to.
|
||||
// The in-flight de-dup collapses N concurrent provisions into ONE.
|
||||
const results = await Promise.all([
|
||||
ensureAccount("Bob"),
|
||||
ensureAccount("Bob"),
|
||||
ensureAccount("@bob"),
|
||||
ensureAccount("BOB"),
|
||||
ensureAccount("bob"),
|
||||
]);
|
||||
// ONE set of 3 scope docs + 1 doc-shim (resolveShimDoc de-dupes concurrent pointer
|
||||
// resolution too) — 4 total, not 5×3.
|
||||
expect(fake.doc_create).toHaveBeenCalledTimes(4);
|
||||
// Every caller got the SAME record (same docs), so writer/reader can never
|
||||
// disagree on the canonical scope doc.
|
||||
for (const r of results) expect(r).toEqual(results[0]!);
|
||||
});
|
||||
|
||||
test("loadShim round-trips a persisted account across a cache reset", async () => {
|
||||
@@ -215,8 +250,6 @@ test("resolveScopeGraph maps scopes to native store NURIs (no store-id leaks to
|
||||
protectedStoreId: "PROT",
|
||||
publicStoreId: "PUB",
|
||||
}),
|
||||
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
||||
provisionRetry: { attempts: 1 },
|
||||
});
|
||||
resetRegistryCache();
|
||||
expect(await resolveScopeGraph("private")).toBe("did:ng:PRIV");
|
||||
@@ -363,10 +396,10 @@ test("normalizeId defaults to trim when not provided", async () => {
|
||||
const ng = makeFakeNg();
|
||||
configure({ ng: ng as any, useShape: (() => {}) as any });
|
||||
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
||||
configureStoreRegistry({ getSession: async () => SESSION, provisionRetry: { attempts: 1 } });
|
||||
configureStoreRegistry({ getSession: async () => SESSION });
|
||||
resetRegistryCache();
|
||||
const a = await ensureAccount(" Ivy ");
|
||||
const b = await ensureAccount("Ivy"); // trimmed key matches
|
||||
expect(b).toEqual(a);
|
||||
expect(ng.doc_create).toHaveBeenCalledTimes(3);
|
||||
expect(ng.doc_create).toHaveBeenCalledTimes(4); // 1 doc-shim + 3 scope docs
|
||||
});
|
||||
|
||||
@@ -50,7 +50,7 @@ function inject(failFor?: Set<string>) {
|
||||
const ng = makeFakeNg(failFor);
|
||||
configure({ ng: ng as any, useShape: (() => {}) as any });
|
||||
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
||||
configureStoreRegistry({ getSession: async () => SESSION, provisionRetry: { attempts: 1 } });
|
||||
configureStoreRegistry({ getSession: async () => SESSION });
|
||||
return ng;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* watch-shape.test.ts — behavioural tests for `watchShape` (src/watch-shape.ts),
|
||||
* against a STATEFUL fake `ng` with a CONTROLLABLE `doc_subscribe`.
|
||||
*
|
||||
* The fake emulates just enough of the broker:
|
||||
* - `doc_create` mints monotonic doc NURIs.
|
||||
* - `sparql_update` parses the shim account writes + the per-entity index
|
||||
* `contains` append + arbitrary anchored triple writes into an in-memory quad
|
||||
* store (same tolerant parser shape as store-registry.test / read-model.test).
|
||||
* - `sparql_query` answers the shim account SELECT, the scope-index `contains`
|
||||
* SELECT, and the anchored per-doc `?s ?p ?o` read (readUnion) — each scoped to
|
||||
* the anchor graph.
|
||||
* - `doc_subscribe` models the platform push order TabInfo→State: on subscribe it
|
||||
* records the callback and fires a `TabInfo` immediately, but the sync BARRIER
|
||||
* `State` is fired only when the TEST releases it (`fireState`) — so we can
|
||||
* assert isPending BEFORE the barrier and isSuccess AFTER. A later write to a
|
||||
* subscribed doc fires a `Patch` push (reactivity).
|
||||
*
|
||||
* These prove the four distinctions the surface exists for:
|
||||
* (a) isPending at first, isSuccess after the first State (barrier);
|
||||
* (b) isSuccess + data:[] on a synced-but-EMPTY scope (the key distinction);
|
||||
* (c) a write then push → data updates (reactivity, no polling);
|
||||
* (d) timed-out → isSuccess (best-effort), NOT isError.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, mock, beforeEach, afterEach, afterAll } from "bun:test";
|
||||
import { watchShape } from "../src/watch-shape";
|
||||
import {
|
||||
configure,
|
||||
configureStoreRegistry,
|
||||
resetStoreRegistry,
|
||||
resetConfig,
|
||||
setCurrentUser,
|
||||
} from "../src/polyfill";
|
||||
import { resetRegistryCache, createEntityDoc } from "../src/store-registry";
|
||||
import { resetOpenedRepos, setOpenTimeoutForTests, getSyncState } from "../src/open-repo";
|
||||
|
||||
const TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
|
||||
const FP = "http://festipod.org/";
|
||||
const SESSION = { sessionId: "sid-ws", privateStoreId: "PRIV-WS" };
|
||||
|
||||
interface Quad { g: string; s: string; p: string; o: string }
|
||||
|
||||
/** Reverse of escapeLiteral: single left-to-right pass over `\x`. */
|
||||
function unescapeLiteral(s: string): string {
|
||||
let out = "";
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
if (s[i] === "\\" && i + 1 < s.length) {
|
||||
const next = s[++i];
|
||||
out += next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next!;
|
||||
} else out += s[i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface SubRec { nuri: string; cb: (r: unknown) => void }
|
||||
|
||||
/**
|
||||
* The stateful fake with a controllable doc_subscribe. `holdState: true` means a
|
||||
* fresh subscription does NOT auto-fire its `State` — the test fires it via
|
||||
* `fireState(nuri)`. `holdState: false` (default) auto-fires `State` on subscribe
|
||||
* (synced immediately), which is the convenient mode for the reactivity/empty cases.
|
||||
*/
|
||||
function makeFake(opts?: { holdState?: boolean }) {
|
||||
const quads: Quad[] = [];
|
||||
let docCounter = 0;
|
||||
const subs: SubRec[] = [];
|
||||
const hold = opts?.holdState ?? false;
|
||||
// Nuris whose barrier `State` has been released (auto-fire on future subscribe).
|
||||
const released = new Set<string>();
|
||||
// The shim ANCHOR (private-store-root) is ALWAYS loaded/synced on the real broker
|
||||
// (the store repo is bootstrapped at connect), so its barrier `State` is always
|
||||
// available. `resolveAccount`/`ensureAccount` now open it (the cold-start heal)
|
||||
// before touching the shim — pre-release it here so `holdState` (which gates the
|
||||
// per-ENTITY docs the tests control) never blocks the anchor open. This mirrors the
|
||||
// real invariant the production heal relies on.
|
||||
released.add(`did:ng:${SESSION.privateStoreId}`);
|
||||
let releaseEverything = false;
|
||||
|
||||
const doc_create = mock(async () => `did:ng:o:doc${++docCounter}`);
|
||||
|
||||
const sparql_update = mock(async (...a: unknown[]) => {
|
||||
const query = a[1] as string;
|
||||
const anchor = a[2] as string | undefined;
|
||||
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
|
||||
let g: string;
|
||||
let body: string;
|
||||
if (gm) {
|
||||
g = gm[1]!;
|
||||
body = gm[2]!;
|
||||
} else {
|
||||
if (!anchor) return undefined;
|
||||
g = anchor;
|
||||
body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
|
||||
}
|
||||
const sm = body.match(/<([^>]+)>/);
|
||||
if (!sm) return undefined;
|
||||
const s = sm[1]!;
|
||||
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
|
||||
let m: RegExpExecArray | null;
|
||||
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
|
||||
while ((m = pairRe.exec(after)) !== null) {
|
||||
const p = m[1] ?? "urn:ng-eventually:shim:Account";
|
||||
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
|
||||
quads.push({ g, s, p, o });
|
||||
// The doc-shim (named by the write-once pointer triple) is INFRASTRUCTURE, like
|
||||
// the store-root: `doc_create` bootstrapped it into the session, so its barrier
|
||||
// `State` is immediately available. Pre-release it so `holdState` (which gates the
|
||||
// per-ENTITY docs the tests control) never blocks the doc-shim open. The pointer is
|
||||
// published BEFORE the doc-shim barrier open (resolveShimDoc first-login order).
|
||||
if (p === "urn:ng-eventually:shim:shimDoc") {
|
||||
released.add(o);
|
||||
for (const sub of subs) if (sub.nuri === o) sub.cb({ V0: { State: {} } });
|
||||
}
|
||||
}
|
||||
// A write to a subscribed doc fires a Patch push (reactivity signal).
|
||||
for (const sub of subs) {
|
||||
if (sub.nuri === g) sub.cb({ V0: { Patch: {} } });
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const sparql_query = mock(async (...a: unknown[]) => {
|
||||
const query = a[1] as string;
|
||||
const anchor = a[3] as string | undefined;
|
||||
// Pointer SELECT (store-root -> doc-shim).
|
||||
if (query.includes("<urn:ng-eventually:shim:shimDoc>")) {
|
||||
const bindings = quads
|
||||
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:shimDoc")
|
||||
.map((q) => ({ shimDoc: { value: q.o } }));
|
||||
return { results: { bindings } };
|
||||
}
|
||||
if (query.includes("<urn:ng-eventually:shim:id>")) {
|
||||
const subjM = query.match(
|
||||
/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/,
|
||||
);
|
||||
const onlySubject = subjM ? subjM[1]! : null;
|
||||
const bySubject = new Map<string, Record<string, string>>();
|
||||
for (const q of quads) {
|
||||
if (q.g !== anchor) continue;
|
||||
if (onlySubject !== null && q.s !== onlySubject) continue;
|
||||
const rec = bySubject.get(q.s) ?? {};
|
||||
if (q.p === "urn:ng-eventually:shim:id") rec.id = q.o;
|
||||
if (q.p === "urn:ng-eventually:shim:docPublic") rec.docPublic = q.o;
|
||||
if (q.p === "urn:ng-eventually:shim:docProtected") rec.docProtected = q.o;
|
||||
if (q.p === "urn:ng-eventually:shim:docPrivate") rec.docPrivate = q.o;
|
||||
bySubject.set(q.s, rec);
|
||||
}
|
||||
const bindings = [...bySubject.values()]
|
||||
.filter((r) => r.id)
|
||||
.map((r) => ({
|
||||
id: { value: r.id! },
|
||||
docPublic: { value: r.docPublic ?? "" },
|
||||
docProtected: { value: r.docProtected ?? "" },
|
||||
docPrivate: { value: r.docPrivate ?? "" },
|
||||
}));
|
||||
return { results: { bindings } };
|
||||
}
|
||||
if (query.includes("<urn:ng-eventually:shim:contains>")) {
|
||||
const bindings = quads
|
||||
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:contains")
|
||||
.map((q) => ({ e: { value: q.o } }));
|
||||
return { results: { bindings } };
|
||||
}
|
||||
// Anchored per-doc read (readUnion `SELECT ?s ?p ?o`).
|
||||
const bindings = quads
|
||||
.filter((q) => q.g === anchor)
|
||||
.map((q) => ({ s: { value: q.s }, p: { value: q.p }, o: { value: q.o } }));
|
||||
return { results: { bindings } };
|
||||
});
|
||||
|
||||
const doc_subscribe = mock(
|
||||
async (nuri: string, _sid: string, cb: (r: unknown) => void) => {
|
||||
subs.push({ nuri, cb });
|
||||
// Platform pushes TabInfo FIRST (never the barrier).
|
||||
setTimeout(() => cb({ V0: { TabInfo: {} } }), 0);
|
||||
// Fire the barrier State if this fake auto-syncs, or if this nuri was already
|
||||
// released (so a doc subscribed AFTER a release still crosses the barrier).
|
||||
if (!hold || releaseEverything || released.has(nuri)) {
|
||||
setTimeout(() => cb({ V0: { State: {} } }), 0);
|
||||
}
|
||||
return () => {};
|
||||
},
|
||||
);
|
||||
|
||||
/** Release the barrier for `nuri` (fire State now + auto-fire for future subs). */
|
||||
function fireState(nuri: string): void {
|
||||
released.add(nuri);
|
||||
for (const sub of subs) if (sub.nuri === nuri) sub.cb({ V0: { State: {} } });
|
||||
}
|
||||
/** Release the barrier for EVERY doc, present and future. */
|
||||
function releaseAll(): void {
|
||||
releaseEverything = true;
|
||||
for (const sub of subs) sub.cb({ V0: { State: {} } });
|
||||
}
|
||||
|
||||
return {
|
||||
doc_create,
|
||||
sparql_update,
|
||||
sparql_query,
|
||||
doc_subscribe,
|
||||
_quads: quads,
|
||||
fireState,
|
||||
releaseAll,
|
||||
subs,
|
||||
};
|
||||
}
|
||||
|
||||
function inject(ng: ReturnType<typeof makeFake>) {
|
||||
configure({ ng: ng as any, useShape: (() => {}) as any });
|
||||
configureStoreRegistry({
|
||||
getSession: async () => SESSION,
|
||||
normalizeId: (u: string) => u.trim().replace(/^@+/, "").toLowerCase(),
|
||||
});
|
||||
resetRegistryCache();
|
||||
resetOpenedRepos();
|
||||
}
|
||||
|
||||
// Insert a triple straight into a doc's graph in the fake store (no push).
|
||||
function seed(ng: ReturnType<typeof makeFake>, doc: string, p: string, o: string): void {
|
||||
ng._quads.push({ g: doc, s: doc, p, o });
|
||||
}
|
||||
|
||||
const tick = () => new Promise((r) => setTimeout(r, 5));
|
||||
|
||||
// A minimal SHEX ShapeType pinning rdf:type to `${FP}Event`.
|
||||
const EventShape = {
|
||||
shape: `${FP}EventShape`,
|
||||
schema: {
|
||||
[`${FP}EventShape`]: {
|
||||
iri: `${FP}EventShape`,
|
||||
predicates: [{ iri: TYPE, dataTypes: [{ literals: [`${FP}Event`], valType: "iri" }] }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
setCurrentUser(null);
|
||||
});
|
||||
afterAll(() => {
|
||||
resetConfig();
|
||||
resetStoreRegistry();
|
||||
resetRegistryCache();
|
||||
resetOpenedRepos();
|
||||
});
|
||||
|
||||
describe("watchShape", () => {
|
||||
it("(a) isPending at first, then isSuccess after the first State (barrier)", async () => {
|
||||
const ng = makeFake({ holdState: true });
|
||||
inject(ng);
|
||||
setCurrentUser("alice");
|
||||
// One protected entity doc for alice, carrying an Event triple.
|
||||
const doc = await createEntityDoc("alice", "protected");
|
||||
seed(ng, doc, TYPE, `${FP}Event`);
|
||||
seed(ng, doc, `${FP}title`, "Alpha");
|
||||
|
||||
const obs = watchShape(EventShape, "protected");
|
||||
let notes = 0;
|
||||
const unsub = obs.subscribe(() => {
|
||||
notes += 1;
|
||||
});
|
||||
|
||||
// Before the barrier: pending, no data.
|
||||
await tick();
|
||||
expect(obs.getSnapshot().isPending).toBe(true);
|
||||
expect(obs.getSnapshot().isSuccess).toBe(false);
|
||||
expect(obs.getSnapshot().data).toEqual([]);
|
||||
|
||||
// Release the barrier for every opened doc (present + future) → synced.
|
||||
ng.releaseAll();
|
||||
await tick();
|
||||
await tick();
|
||||
await tick();
|
||||
|
||||
const snap = obs.getSnapshot();
|
||||
expect(snap.isPending).toBe(false);
|
||||
expect(snap.isSuccess).toBe(true);
|
||||
expect(snap.isError).toBe(false);
|
||||
expect(snap.data.length).toBe(1);
|
||||
expect(snap.data[0]!.props[`${FP}title`]).toEqual(["Alpha"]);
|
||||
expect(notes).toBeGreaterThan(0);
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("(b) isSuccess + data:[] on a synced-but-EMPTY scope (the key distinction)", async () => {
|
||||
const ng = makeFake(); // auto-fires State → synced immediately
|
||||
inject(ng);
|
||||
setCurrentUser("bob");
|
||||
// bob has NO entity docs in this scope — the scope is genuinely empty.
|
||||
|
||||
const obs = watchShape(EventShape, "protected");
|
||||
const unsub = obs.subscribe(() => {});
|
||||
await tick();
|
||||
await tick();
|
||||
|
||||
const snap = obs.getSnapshot();
|
||||
expect(snap.isPending).toBe(false);
|
||||
expect(snap.isSuccess).toBe(true); // synced, NOT stuck pending
|
||||
expect(snap.isError).toBe(false);
|
||||
expect(snap.data).toEqual([]); // empty — distinguishable from "still syncing"
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("(c) a write then push updates data (reactivity, no polling)", async () => {
|
||||
const ng = makeFake(); // synced immediately
|
||||
inject(ng);
|
||||
setCurrentUser("carol");
|
||||
const doc = await createEntityDoc("carol", "protected");
|
||||
seed(ng, doc, TYPE, `${FP}Event`);
|
||||
seed(ng, doc, `${FP}title`, "One");
|
||||
|
||||
const obs = watchShape(EventShape, "protected");
|
||||
const unsub = obs.subscribe(() => {});
|
||||
await tick();
|
||||
await tick();
|
||||
expect(obs.getSnapshot().data.length).toBe(1);
|
||||
|
||||
// Write a SECOND event doc + fire the push via a write to the ALREADY-subscribed
|
||||
// doc. Because a new doc must appear in the set, write into the scope-INDEX
|
||||
// (createEntityDoc appends to it, and the index is subscribed → re-resolve).
|
||||
const doc2 = await createEntityDoc("carol", "protected");
|
||||
seed(ng, doc2, TYPE, `${FP}Event`);
|
||||
seed(ng, doc2, `${FP}title`, "Two");
|
||||
// createEntityDoc's index append fired a Patch on the index doc → re-resolve.
|
||||
await tick();
|
||||
await tick();
|
||||
|
||||
const titles = obs
|
||||
.getSnapshot()
|
||||
.data.flatMap((s) => s.props[`${FP}title`] ?? [])
|
||||
.sort();
|
||||
expect(titles).toEqual(["One", "Two"]);
|
||||
// No setInterval anywhere — reactivity was push-driven.
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("(d) timed-out → isSuccess (best-effort), NOT isError", async () => {
|
||||
// A doc whose subscription NEVER pushes a `State`: open-repo's bounded fallback
|
||||
// fires and marks the nuri "timed-out" (NOT "synced"). We shrink the fallback to
|
||||
// a few ms so this is fast, and assert the barrier is genuinely reached via
|
||||
// timed-out (getSyncState === "timed-out") and that the snapshot maps that to
|
||||
// isSuccess, never isError.
|
||||
const ng = makeFake({ holdState: true }); // State is never released
|
||||
inject(ng);
|
||||
setOpenTimeoutForTests(20); // fallback fires quickly instead of after 8s
|
||||
setCurrentUser("dave");
|
||||
const doc = await createEntityDoc("dave", "protected");
|
||||
seed(ng, doc, TYPE, `${FP}Event`);
|
||||
seed(ng, doc, `${FP}title`, "Timed");
|
||||
|
||||
const obs = watchShape(EventShape, "protected");
|
||||
const unsub = obs.subscribe(() => {});
|
||||
await tick();
|
||||
// Before the fallback fires: still pending (subscribed, no State).
|
||||
expect(obs.getSnapshot().isPending).toBe(true);
|
||||
|
||||
// Let the bounded fallback elapse → open-repo marks each opened doc timed-out.
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
await tick();
|
||||
await tick();
|
||||
|
||||
// The entity doc's barrier resolved via timed-out (never a State).
|
||||
expect(getSyncState(doc)).toBe("timed-out");
|
||||
const snap = obs.getSnapshot();
|
||||
expect(snap.isError).toBe(false);
|
||||
expect(snap.isSuccess).toBe(true); // timed-out is best-effort success
|
||||
expect(snap.isPending).toBe(false);
|
||||
// The data still read (best-effort): the doc's triples resolved.
|
||||
expect(snap.data.length).toBe(1);
|
||||
unsub();
|
||||
});
|
||||
});
|
||||
Generated
+939
@@ -0,0 +1,939 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
devDependencies:
|
||||
'@types/bun':
|
||||
specifier: latest
|
||||
version: 1.3.14
|
||||
playwright:
|
||||
specifier: ^1.61.1
|
||||
version: 1.61.1
|
||||
typescript:
|
||||
specifier: ^5.6.0
|
||||
version: 5.9.3
|
||||
|
||||
packages/client:
|
||||
dependencies:
|
||||
'@ng-org/orm':
|
||||
specifier: '*'
|
||||
version: 0.1.2-alpha.19
|
||||
devDependencies:
|
||||
'@ng-org/alien-deepsignals':
|
||||
specifier: 0.1.2-alpha.11
|
||||
version: 0.1.2-alpha.11
|
||||
'@ng-org/shex-orm':
|
||||
specifier: 0.1.2-alpha.8
|
||||
version: 0.1.2-alpha.8(typescript@5.9.3)
|
||||
'@ng-org/web':
|
||||
specifier: 0.1.2-alpha.13
|
||||
version: 0.1.2-alpha.13
|
||||
|
||||
packages:
|
||||
|
||||
'@ldo/traverser-shexj@1.0.0-alpha.28':
|
||||
resolution: {integrity: sha512-N06+LOWhv6//unPRLbFMd56MqPf5lO2ihZgle9hNLmxt6QJmNrZM3oXzHCL3TfDu4OT1/NUZp3kj2HmztQIZkg==}
|
||||
|
||||
'@ldo/type-traverser@1.0.0-alpha.28':
|
||||
resolution: {integrity: sha512-pGMIVxLzoLjYVhADuVhg6r5ZDNleXZ9DcyIvLXo1/ADEocLnysg/Xjk9D/7l/Rw3WtDJrTFOOtBv8OnH+VPgKA==}
|
||||
|
||||
'@ng-org/alien-deepsignals@0.1.2-alpha.11':
|
||||
resolution: {integrity: sha512-nPgqOrheAda/pW5FHgSb45SrSZWuyMyEVqO683ijEsVPpD105bngfh92PPfcRoRnFzGSoKXa3CfuqUHi2+qVIQ==}
|
||||
peerDependencies:
|
||||
react: ^19.0.0 || ^18.0.0
|
||||
svelte: ^5.0.0 || ^4.0.0
|
||||
vue: ^3.0.0
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
svelte:
|
||||
optional: true
|
||||
vue:
|
||||
optional: true
|
||||
|
||||
'@ng-org/alien-deepsignals@0.1.2-alpha.12':
|
||||
resolution: {integrity: sha512-STIfMLJvwNDnAIdi/wFToLtnZDjbf0PaeYfshfMal2HbYirSPFEBue393nlYKv9rLlCrqjYOBNeSBK9FO7/Qng==}
|
||||
peerDependencies:
|
||||
react: ^19.0.0 || ^18.0.0
|
||||
svelte: ^5.0.0 || ^4.0.0
|
||||
vue: ^3.0.0
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
svelte:
|
||||
optional: true
|
||||
vue:
|
||||
optional: true
|
||||
|
||||
'@ng-org/orm@0.1.2-alpha.19':
|
||||
resolution: {integrity: sha512-wPDQ0OEitpoEKrvj4IJKIRxeTHr3X5+rApvZJOh54CNV1Q1b5MaT2XvyJ9YDvlgyUX2CxNkcmumV4k4ASmBXNA==}
|
||||
peerDependencies:
|
||||
react: ^19.0.0 || ^18.0.0
|
||||
svelte: ^5.0.0 || ^4.0.0
|
||||
vue: ^3.0.0
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
svelte:
|
||||
optional: true
|
||||
vue:
|
||||
optional: true
|
||||
|
||||
'@ng-org/shex-orm@0.1.2-alpha.8':
|
||||
resolution: {integrity: sha512-jogxiLfUtI3WkuF5O3PPwRBRWylpapk/JlvElGUqZnEavXXlEMmEGbK3VwqMUsbp1pweNA0sEKIFLIk3Lzt2Gg==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
typescript: ^4.9.0 || ^5.0.0 || ^6.0.0
|
||||
|
||||
'@ng-org/web@0.1.2-alpha.13':
|
||||
resolution: {integrity: sha512-/xO0c+3NTphnws5Do2LDqgZWmAf+aNnYdChJKdU0dnp1U1iVSgi/y3yb8AYryf0v9sooj0aYJxt08B6DpirFMQ==}
|
||||
|
||||
'@rdfjs/types@1.1.2':
|
||||
resolution: {integrity: sha512-wqpOJK1QCbmsGNtyzYnojPU8gRDPid2JO0Q0kMtb4j65xhCK880cnKAfEOwC+dX85VJcCByQx5zOwyyfCjDJsg==}
|
||||
|
||||
'@shexjs/parser@1.0.0-alpha.28':
|
||||
resolution: {integrity: sha512-eeVeHq/2JG9X+3h7y+7EmuBSWWl2EMj/EQBLk5CTRx4W4hWDdjWczsY8RWwKjkIzLwUS1+G0aiAI1u5LHCZ2Rw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
'@shexjs/term@1.0.0-alpha.27':
|
||||
resolution: {integrity: sha512-+D7P7pglRPTZC2RkwaQuq+cgBZImx+61JZtcN77uEJVqcGaIscQK5hScsKhAPIo16/I+4jhIUCEFojXqw6otpg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
'@shexjs/util@1.0.0-alpha.28':
|
||||
resolution: {integrity: sha512-L8pBokTU/5eNRJPkC8R9SIgPw6/JDh/bHKdV5TZzf8/FkOMNJwKIy6UDHXM1I8FJ+c8u2gOOHp2MA+7b+md+0A==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
'@shexjs/visitor@1.0.0-alpha.27':
|
||||
resolution: {integrity: sha512-9s67A+f0ZZNw/SNxqoi1483CqUca8dbnHM6WDWsRH4+eXlQpQqwOZDxA8uKEaWeX4VcDrDwzWpr0WvK6EyDWIQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
'@ts-jison/common@0.4.1-alpha.1':
|
||||
resolution: {integrity: sha512-SDbHzq+UMD+V3ciKVBHwCEgVqSeyQPTCjOsd/ZNTGySUVg4x3EauR9ZcEfdVFAsYRR38XWgDI+spq5LDY46KvQ==}
|
||||
|
||||
'@ts-jison/lexer@0.4.1-alpha.1':
|
||||
resolution: {integrity: sha512-5C1Wr+wixAzn2MOFtgy7KbT6N6j9mhmbjAtyvOqZKsikKtNOQj22MM5HxT+ooRexG2NbtxnDSXYdhHR1Lg58ow==}
|
||||
|
||||
'@ts-jison/parser@0.4.1-alpha.1':
|
||||
resolution: {integrity: sha512-xNj+qOez/7dju44LlYiTlCjxMzW5oek9EckUAElfln/GBK9vgMSk0swWcnacMr0TYbGjUQuXvL2wEgmDf5WajQ==}
|
||||
|
||||
'@types/bun@1.3.14':
|
||||
resolution: {integrity: sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==}
|
||||
|
||||
'@types/concat-stream@1.6.1':
|
||||
resolution: {integrity: sha512-eHE4cQPoj6ngxBZMvVf6Hw7Mh4jMW4U9lpGmS5GBPB9RYxlFg+CHaVN7ErNY4W9XfLIEn20b4VDYaIrbq0q4uA==}
|
||||
|
||||
'@types/form-data@0.0.33':
|
||||
resolution: {integrity: sha512-8BSvG1kGm83cyJITQMZSulnl6QV8jqAGreJsc5tPu1Jq0vTSOiY/k24Wx82JRpWwZSqrala6sd5rWi6aNXvqcw==}
|
||||
|
||||
'@types/http-link-header@1.0.7':
|
||||
resolution: {integrity: sha512-snm5oLckop0K3cTDAiBnZDy6ncx9DJ3mCRDvs42C884MbVYPP74Tiq2hFsSDRTyjK6RyDYDIulPiW23ge+g5Lw==}
|
||||
|
||||
'@types/jsonld@1.5.15':
|
||||
resolution: {integrity: sha512-PlAFPZjL+AuGYmwlqwKEL0IMP8M8RexH0NIPGfCVWSQ041H2rR/8OlyZSD7KsCVoN8vCfWdtWDBxX8yBVP+xow==}
|
||||
|
||||
'@types/node@10.17.60':
|
||||
resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==}
|
||||
|
||||
'@types/node@18.19.130':
|
||||
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
|
||||
|
||||
'@types/node@26.1.1':
|
||||
resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==}
|
||||
|
||||
'@types/node@8.10.66':
|
||||
resolution: {integrity: sha512-tktOkFUA4kXx2hhhrB8bIFb5TbwzS4uOhKEmwiD+NoiL0qtP2OQ9mFldbgD4dV1djrlBYP6eBuQZiWjuHUpqFw==}
|
||||
|
||||
'@types/qs@6.15.1':
|
||||
resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==}
|
||||
|
||||
'@types/shexj@2.1.7':
|
||||
resolution: {integrity: sha512-pu/0vIZxFTMPVjTlo5MJKFkBL/EbAuFhtCXpmBB7ZdUiyNpc6pt8GxfyRPqdf6q2SsWu71a/vbhvGK2IZN2Eug==}
|
||||
|
||||
alien-signals@2.0.8:
|
||||
resolution: {integrity: sha512-844G1VLkk0Pe2SJjY0J8vp8ADI73IM4KliNu2OGlYzWpO28NexEUvjHTcFjFX3VXoiUtwTbHxLNI9ImkcoBqzA==}
|
||||
|
||||
asap@2.0.6:
|
||||
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
|
||||
|
||||
async-proxy@0.4.1:
|
||||
resolution: {integrity: sha512-4e+zNtoGL4+cnqib8v169CnKcRfAsAubp2EsjBhAA5jyW7jjI3t36rVvuqLwmhtliwf8JvSnxinE4ecQN+DK4w==}
|
||||
|
||||
async@3.2.6:
|
||||
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
|
||||
|
||||
asynckit@0.4.0:
|
||||
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||
|
||||
balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
|
||||
brace-expansion@2.1.2:
|
||||
resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==}
|
||||
|
||||
buffer-from@1.1.2:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
||||
bun-types@1.3.14:
|
||||
resolution: {integrity: sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==}
|
||||
|
||||
call-bind-apply-helpers@1.0.2:
|
||||
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
call-bound@1.0.4:
|
||||
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
caseless@0.12.0:
|
||||
resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==}
|
||||
|
||||
colors-cli@1.0.33:
|
||||
resolution: {integrity: sha512-PWGsmoJFdOB0t+BeHgmtuoRZUQucOLl5ii81NBzOOGVxlgE04muFNHlR5j8i8MKbOPELBl3243AI6lGBTj5ICQ==}
|
||||
hasBin: true
|
||||
|
||||
combined-stream@1.0.8:
|
||||
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
commander@14.0.3:
|
||||
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
concat-stream@1.6.2:
|
||||
resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==}
|
||||
engines: {'0': node >= 0.8}
|
||||
|
||||
core-util-is@1.0.3:
|
||||
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||
|
||||
cross-fetch@3.2.0:
|
||||
resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==}
|
||||
|
||||
delayed-stream@1.0.0:
|
||||
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
||||
dts-dom@3.6.0:
|
||||
resolution: {integrity: sha512-on5jxTgt+A6r0Zyyz6ZRHXaAO7J1VPnOd6+AmvI1vH440AlAZZNc5rUHzgPuTjGlrVr1rOWQYNl7ZJK6rDohbw==}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
ejs@3.1.10:
|
||||
resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
hasBin: true
|
||||
|
||||
es-define-property@1.0.1:
|
||||
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-errors@1.3.0:
|
||||
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-object-atoms@1.1.2:
|
||||
resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-set-tostringtag@2.1.0:
|
||||
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
filelist@1.0.6:
|
||||
resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==}
|
||||
|
||||
form-data@2.5.6:
|
||||
resolution: {integrity: sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==}
|
||||
engines: {node: '>= 0.12'}
|
||||
|
||||
fs-extra@10.1.0:
|
||||
resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
fsevents@2.3.2:
|
||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
function-bind@1.1.2:
|
||||
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
get-port@3.2.0:
|
||||
resolution: {integrity: sha512-x5UJKlgeUiNT8nyo/AcnwLnZuZNcSjSw0kogRB+Whd1fjjFq4B1hySFxSFWWSn4mIBzg3sRNUDFYc4g5gjPoLg==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
get-proto@1.0.1:
|
||||
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
gopd@1.2.0:
|
||||
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
has-symbols@1.1.0:
|
||||
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
has-tostringtag@1.0.2:
|
||||
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hasown@2.0.4:
|
||||
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hierarchy-closure@1.2.2:
|
||||
resolution: {integrity: sha512-ZqZvsA6HyMqrmm49D3llYA8x8hqdyDDEkaTXcqwyO+fGQlzxoeXws/5ze11M40s4EoTw7GFxdTKIwj5YDOicLQ==}
|
||||
|
||||
http-basic@8.1.3:
|
||||
resolution: {integrity: sha512-/EcDMwJZh3mABI2NhGfHOGOeOZITqfkEO4p/xK+l3NpyncIHUQBoMvCSF/b5GqvKtySC2srL/GGG3+EtlqlmCw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
http-link-header@1.1.4:
|
||||
resolution: {integrity: sha512-xT3GPW6/ZbGuw4UvwHqErSCEjNUlwbQJuZn9/q5U4WEKfp2kENVCAlousG1zLxHeaQ/ffOHUNpWamvkbBW0eNw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
http-response-object@3.0.2:
|
||||
resolution: {integrity: sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==}
|
||||
|
||||
inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
|
||||
isarray@1.0.0:
|
||||
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
||||
|
||||
jake@10.9.4:
|
||||
resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
jsonfile@6.2.1:
|
||||
resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==}
|
||||
|
||||
jsonld-context-parser@2.4.0:
|
||||
resolution: {integrity: sha512-ZYOfvh525SdPd9ReYY58dxB3E2RUEU4DJ6ZibO8AitcowPeBH4L5rCAitE2om5G1P+HMEgYEYEr4EZKbVN4tpA==}
|
||||
hasBin: true
|
||||
|
||||
jsonld2graphobject@0.0.5:
|
||||
resolution: {integrity: sha512-5BqfXOq96+OBjjiJNG8gQH66pYt6hW88z2SJxdvFJo4XNoVMvqAcUz+JSm/KEWS5NLRnebApEzFrYP3HUiUmYw==}
|
||||
|
||||
loading-cli@1.1.2:
|
||||
resolution: {integrity: sha512-M1ntfXHpdGoQxfaqKBOQPwSrTr9EIoTgj664Q9UVSbSnJvAFdribo+Ij//1jvACgrGHaTvfKoD9PG3NOxGj44g==}
|
||||
|
||||
math-intrinsics@1.1.0:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
mime-db@1.52.0:
|
||||
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mime-types@2.1.35:
|
||||
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
minimatch@5.1.9:
|
||||
resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
node-fetch@2.7.0:
|
||||
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
|
||||
engines: {node: 4.x || >=6.0.0}
|
||||
peerDependencies:
|
||||
encoding: ^0.1.0
|
||||
peerDependenciesMeta:
|
||||
encoding:
|
||||
optional: true
|
||||
|
||||
object-inspect@1.13.4:
|
||||
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
object-path-operator@3.0.0:
|
||||
resolution: {integrity: sha512-Z7dlPUeXqRU/lLfGerP24dPC66n7ehyXaTM81k71EFlsaaEjOHkf4/uq1WGicfGfiO7snYShneE1YZZUkyRiLQ==}
|
||||
|
||||
parse-cache-control@1.0.1:
|
||||
resolution: {integrity: sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
playwright-core@1.61.1:
|
||||
resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
playwright@1.61.1:
|
||||
resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
prettier@3.9.5:
|
||||
resolution: {integrity: sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==}
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
|
||||
process-nextick-args@2.0.1:
|
||||
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||
|
||||
promise@8.3.0:
|
||||
resolution: {integrity: sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==}
|
||||
|
||||
qs@6.15.3:
|
||||
resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
rdf-data-factory@1.1.3:
|
||||
resolution: {integrity: sha512-ny6CI7m2bq4lfQQmDYvcb2l1F9KtGwz9chipX4oWu2aAtVoXjb7k3d8J1EsgAsEbMXnBipB/iuRen5H2fwRWWQ==}
|
||||
|
||||
readable-stream@2.3.8:
|
||||
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
|
||||
|
||||
relative-to-absolute-iri@1.0.8:
|
||||
resolution: {integrity: sha512-U1TmhrhCmXKkDL9mI8gBbF5TN6TKcuv28k5+H3gMCAjoz0TyyHAICHlaGDZsTEBSu2Y3HhDKc8e6X9n33qeIqA==}
|
||||
|
||||
relativize-url@0.1.0:
|
||||
resolution: {integrity: sha512-YXet4a9wQP96Ru9MQSfoRUzsCaeboLPXj+rVG1ulH4t54zqFHiNmW6FPl7V2dTxk9uHlW3yb9+1jWO44AdWisw==}
|
||||
|
||||
safe-buffer@5.1.2:
|
||||
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
|
||||
|
||||
safe-buffer@5.2.1:
|
||||
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
||||
|
||||
side-channel-list@1.0.1:
|
||||
resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
side-channel-map@1.0.1:
|
||||
resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
side-channel-weakmap@1.0.2:
|
||||
resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
side-channel@1.1.1:
|
||||
resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
string_decoder@1.1.1:
|
||||
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
|
||||
|
||||
sync-request@6.1.0:
|
||||
resolution: {integrity: sha512-8fjNkrNlNCrVc/av+Jn+xxqfCjYaBoHqCsDz6mt030UMxJGr+GSfCV1dQt2gRtlL63+VPidwDVLr7V2OcTSdRw==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
sync-rpc@1.3.7:
|
||||
resolution: {integrity: sha512-YHciI7TUxL8EPqz/bg01sZfwuzQA0odao1wf1Ywdtw7j5vl30aQ6s+bLRTvgPPgzr94cg+WMm6Bxi/P7BJOxgw==}
|
||||
|
||||
then-request@6.0.2:
|
||||
resolution: {integrity: sha512-3ZBiG7JvP3wbDzA9iNY5zJQcHL4jn/0BWtXIkagfz7QgOL/LqjCEOBQuJNZfu0XYnv5JhKh+cDxCPM4ILrqruA==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
tr46@0.0.3:
|
||||
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
|
||||
|
||||
typedarray@0.0.6:
|
||||
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
undici-types@5.26.5:
|
||||
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
|
||||
|
||||
undici-types@8.3.0:
|
||||
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
|
||||
|
||||
universalify@2.0.1:
|
||||
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
|
||||
util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
uuid@8.3.2:
|
||||
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
||||
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
|
||||
hasBin: true
|
||||
|
||||
webidl-conversions@3.0.1:
|
||||
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
|
||||
|
||||
whatwg-url@5.0.0:
|
||||
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@ldo/traverser-shexj@1.0.0-alpha.28':
|
||||
dependencies:
|
||||
'@ldo/type-traverser': 1.0.0-alpha.28
|
||||
|
||||
'@ldo/type-traverser@1.0.0-alpha.28':
|
||||
dependencies:
|
||||
uuid: 8.3.2
|
||||
|
||||
'@ng-org/alien-deepsignals@0.1.2-alpha.11':
|
||||
dependencies:
|
||||
alien-signals: 2.0.8
|
||||
|
||||
'@ng-org/alien-deepsignals@0.1.2-alpha.12':
|
||||
dependencies:
|
||||
alien-signals: 2.0.8
|
||||
|
||||
'@ng-org/orm@0.1.2-alpha.19':
|
||||
dependencies:
|
||||
'@ng-org/alien-deepsignals': 0.1.2-alpha.12
|
||||
|
||||
'@ng-org/shex-orm@0.1.2-alpha.8(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@ldo/traverser-shexj': 1.0.0-alpha.28
|
||||
'@ldo/type-traverser': 1.0.0-alpha.28
|
||||
'@shexjs/parser': 1.0.0-alpha.28
|
||||
commander: 14.0.3
|
||||
dts-dom: 3.6.0
|
||||
ejs: 3.1.10
|
||||
fs-extra: 10.1.0
|
||||
jsonld2graphobject: 0.0.5
|
||||
loading-cli: 1.1.2
|
||||
prettier: 3.9.5
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
|
||||
'@ng-org/web@0.1.2-alpha.13':
|
||||
dependencies:
|
||||
async-proxy: 0.4.1
|
||||
|
||||
'@rdfjs/types@1.1.2':
|
||||
dependencies:
|
||||
'@types/node': 26.1.1
|
||||
|
||||
'@shexjs/parser@1.0.0-alpha.28':
|
||||
dependencies:
|
||||
'@shexjs/util': 1.0.0-alpha.28
|
||||
'@ts-jison/parser': 0.4.1-alpha.1
|
||||
|
||||
'@shexjs/term@1.0.0-alpha.27':
|
||||
dependencies:
|
||||
'@types/shexj': 2.1.7
|
||||
rdf-data-factory: 1.1.3
|
||||
relativize-url: 0.1.0
|
||||
|
||||
'@shexjs/util@1.0.0-alpha.28':
|
||||
dependencies:
|
||||
'@shexjs/term': 1.0.0-alpha.27
|
||||
'@shexjs/visitor': 1.0.0-alpha.27
|
||||
'@types/shexj': 2.1.7
|
||||
hierarchy-closure: 1.2.2
|
||||
sync-request: 6.1.0
|
||||
|
||||
'@shexjs/visitor@1.0.0-alpha.27': {}
|
||||
|
||||
'@ts-jison/common@0.4.1-alpha.1': {}
|
||||
|
||||
'@ts-jison/lexer@0.4.1-alpha.1':
|
||||
dependencies:
|
||||
'@ts-jison/common': 0.4.1-alpha.1
|
||||
|
||||
'@ts-jison/parser@0.4.1-alpha.1':
|
||||
dependencies:
|
||||
'@ts-jison/common': 0.4.1-alpha.1
|
||||
'@ts-jison/lexer': 0.4.1-alpha.1
|
||||
|
||||
'@types/bun@1.3.14':
|
||||
dependencies:
|
||||
bun-types: 1.3.14
|
||||
|
||||
'@types/concat-stream@1.6.1':
|
||||
dependencies:
|
||||
'@types/node': 8.10.66
|
||||
|
||||
'@types/form-data@0.0.33':
|
||||
dependencies:
|
||||
'@types/node': 8.10.66
|
||||
|
||||
'@types/http-link-header@1.0.7':
|
||||
dependencies:
|
||||
'@types/node': 18.19.130
|
||||
|
||||
'@types/jsonld@1.5.15': {}
|
||||
|
||||
'@types/node@10.17.60': {}
|
||||
|
||||
'@types/node@18.19.130':
|
||||
dependencies:
|
||||
undici-types: 5.26.5
|
||||
|
||||
'@types/node@26.1.1':
|
||||
dependencies:
|
||||
undici-types: 8.3.0
|
||||
|
||||
'@types/node@8.10.66': {}
|
||||
|
||||
'@types/qs@6.15.1': {}
|
||||
|
||||
'@types/shexj@2.1.7': {}
|
||||
|
||||
alien-signals@2.0.8: {}
|
||||
|
||||
asap@2.0.6: {}
|
||||
|
||||
async-proxy@0.4.1:
|
||||
dependencies:
|
||||
object-path-operator: 3.0.0
|
||||
|
||||
async@3.2.6: {}
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
|
||||
brace-expansion@2.1.2:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
|
||||
buffer-from@1.1.2: {}
|
||||
|
||||
bun-types@1.3.14:
|
||||
dependencies:
|
||||
'@types/node': 26.1.1
|
||||
|
||||
call-bind-apply-helpers@1.0.2:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
function-bind: 1.1.2
|
||||
|
||||
call-bound@1.0.4:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
get-intrinsic: 1.3.0
|
||||
|
||||
caseless@0.12.0: {}
|
||||
|
||||
colors-cli@1.0.33: {}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
dependencies:
|
||||
delayed-stream: 1.0.0
|
||||
|
||||
commander@14.0.3: {}
|
||||
|
||||
concat-stream@1.6.2:
|
||||
dependencies:
|
||||
buffer-from: 1.1.2
|
||||
inherits: 2.0.4
|
||||
readable-stream: 2.3.8
|
||||
typedarray: 0.0.6
|
||||
|
||||
core-util-is@1.0.3: {}
|
||||
|
||||
cross-fetch@3.2.0:
|
||||
dependencies:
|
||||
node-fetch: 2.7.0
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
|
||||
delayed-stream@1.0.0: {}
|
||||
|
||||
dts-dom@3.6.0: {}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
ejs@3.1.10:
|
||||
dependencies:
|
||||
jake: 10.9.4
|
||||
|
||||
es-define-property@1.0.1: {}
|
||||
|
||||
es-errors@1.3.0: {}
|
||||
|
||||
es-object-atoms@1.1.2:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
|
||||
es-set-tostringtag@2.1.0:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
get-intrinsic: 1.3.0
|
||||
has-tostringtag: 1.0.2
|
||||
hasown: 2.0.4
|
||||
|
||||
filelist@1.0.6:
|
||||
dependencies:
|
||||
minimatch: 5.1.9
|
||||
|
||||
form-data@2.5.6:
|
||||
dependencies:
|
||||
asynckit: 0.4.0
|
||||
combined-stream: 1.0.8
|
||||
es-set-tostringtag: 2.1.0
|
||||
hasown: 2.0.4
|
||||
mime-types: 2.1.35
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
fs-extra@10.1.0:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
jsonfile: 6.2.1
|
||||
universalify: 2.0.1
|
||||
|
||||
fsevents@2.3.2:
|
||||
optional: true
|
||||
|
||||
function-bind@1.1.2: {}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
es-define-property: 1.0.1
|
||||
es-errors: 1.3.0
|
||||
es-object-atoms: 1.1.2
|
||||
function-bind: 1.1.2
|
||||
get-proto: 1.0.1
|
||||
gopd: 1.2.0
|
||||
has-symbols: 1.1.0
|
||||
hasown: 2.0.4
|
||||
math-intrinsics: 1.1.0
|
||||
|
||||
get-port@3.2.0: {}
|
||||
|
||||
get-proto@1.0.1:
|
||||
dependencies:
|
||||
dunder-proto: 1.0.1
|
||||
es-object-atoms: 1.1.2
|
||||
|
||||
gopd@1.2.0: {}
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
has-symbols@1.1.0: {}
|
||||
|
||||
has-tostringtag@1.0.2:
|
||||
dependencies:
|
||||
has-symbols: 1.1.0
|
||||
|
||||
hasown@2.0.4:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
hierarchy-closure@1.2.2: {}
|
||||
|
||||
http-basic@8.1.3:
|
||||
dependencies:
|
||||
caseless: 0.12.0
|
||||
concat-stream: 1.6.2
|
||||
http-response-object: 3.0.2
|
||||
parse-cache-control: 1.0.1
|
||||
|
||||
http-link-header@1.1.4: {}
|
||||
|
||||
http-response-object@3.0.2:
|
||||
dependencies:
|
||||
'@types/node': 10.17.60
|
||||
|
||||
inherits@2.0.4: {}
|
||||
|
||||
isarray@1.0.0: {}
|
||||
|
||||
jake@10.9.4:
|
||||
dependencies:
|
||||
async: 3.2.6
|
||||
filelist: 1.0.6
|
||||
picocolors: 1.1.1
|
||||
|
||||
jsonfile@6.2.1:
|
||||
dependencies:
|
||||
universalify: 2.0.1
|
||||
optionalDependencies:
|
||||
graceful-fs: 4.2.11
|
||||
|
||||
jsonld-context-parser@2.4.0:
|
||||
dependencies:
|
||||
'@types/http-link-header': 1.0.7
|
||||
'@types/node': 18.19.130
|
||||
cross-fetch: 3.2.0
|
||||
http-link-header: 1.1.4
|
||||
relative-to-absolute-iri: 1.0.8
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
|
||||
jsonld2graphobject@0.0.5:
|
||||
dependencies:
|
||||
'@rdfjs/types': 1.1.2
|
||||
'@types/jsonld': 1.5.15
|
||||
jsonld-context-parser: 2.4.0
|
||||
uuid: 8.3.2
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
|
||||
loading-cli@1.1.2:
|
||||
dependencies:
|
||||
colors-cli: 1.0.33
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
mime-db@1.52.0: {}
|
||||
|
||||
mime-types@2.1.35:
|
||||
dependencies:
|
||||
mime-db: 1.52.0
|
||||
|
||||
minimatch@5.1.9:
|
||||
dependencies:
|
||||
brace-expansion: 2.1.2
|
||||
|
||||
node-fetch@2.7.0:
|
||||
dependencies:
|
||||
whatwg-url: 5.0.0
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
|
||||
object-path-operator@3.0.0: {}
|
||||
|
||||
parse-cache-control@1.0.1: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
playwright-core@1.61.1: {}
|
||||
|
||||
playwright@1.61.1:
|
||||
dependencies:
|
||||
playwright-core: 1.61.1
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
|
||||
prettier@3.9.5: {}
|
||||
|
||||
process-nextick-args@2.0.1: {}
|
||||
|
||||
promise@8.3.0:
|
||||
dependencies:
|
||||
asap: 2.0.6
|
||||
|
||||
qs@6.15.3:
|
||||
dependencies:
|
||||
es-define-property: 1.0.1
|
||||
side-channel: 1.1.1
|
||||
|
||||
rdf-data-factory@1.1.3:
|
||||
dependencies:
|
||||
'@rdfjs/types': 1.1.2
|
||||
|
||||
readable-stream@2.3.8:
|
||||
dependencies:
|
||||
core-util-is: 1.0.3
|
||||
inherits: 2.0.4
|
||||
isarray: 1.0.0
|
||||
process-nextick-args: 2.0.1
|
||||
safe-buffer: 5.1.2
|
||||
string_decoder: 1.1.1
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
relative-to-absolute-iri@1.0.8: {}
|
||||
|
||||
relativize-url@0.1.0: {}
|
||||
|
||||
safe-buffer@5.1.2: {}
|
||||
|
||||
safe-buffer@5.2.1: {}
|
||||
|
||||
side-channel-list@1.0.1:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
object-inspect: 1.13.4
|
||||
|
||||
side-channel-map@1.0.1:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
es-errors: 1.3.0
|
||||
get-intrinsic: 1.3.0
|
||||
object-inspect: 1.13.4
|
||||
|
||||
side-channel-weakmap@1.0.2:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
es-errors: 1.3.0
|
||||
get-intrinsic: 1.3.0
|
||||
object-inspect: 1.13.4
|
||||
side-channel-map: 1.0.1
|
||||
|
||||
side-channel@1.1.1:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
object-inspect: 1.13.4
|
||||
side-channel-list: 1.0.1
|
||||
side-channel-map: 1.0.1
|
||||
side-channel-weakmap: 1.0.2
|
||||
|
||||
string_decoder@1.1.1:
|
||||
dependencies:
|
||||
safe-buffer: 5.1.2
|
||||
|
||||
sync-request@6.1.0:
|
||||
dependencies:
|
||||
http-response-object: 3.0.2
|
||||
sync-rpc: 1.3.7
|
||||
then-request: 6.0.2
|
||||
|
||||
sync-rpc@1.3.7:
|
||||
dependencies:
|
||||
get-port: 3.2.0
|
||||
|
||||
then-request@6.0.2:
|
||||
dependencies:
|
||||
'@types/concat-stream': 1.6.1
|
||||
'@types/form-data': 0.0.33
|
||||
'@types/node': 8.10.66
|
||||
'@types/qs': 6.15.1
|
||||
caseless: 0.12.0
|
||||
concat-stream: 1.6.2
|
||||
form-data: 2.5.6
|
||||
http-basic: 8.1.3
|
||||
http-response-object: 3.0.2
|
||||
promise: 8.3.0
|
||||
qs: 6.15.3
|
||||
|
||||
tr46@0.0.3: {}
|
||||
|
||||
typedarray@0.0.6: {}
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
undici-types@5.26.5: {}
|
||||
|
||||
undici-types@8.3.0: {}
|
||||
|
||||
universalify@2.0.1: {}
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
uuid@8.3.2: {}
|
||||
|
||||
webidl-conversions@3.0.1: {}
|
||||
|
||||
whatwg-url@5.0.0:
|
||||
dependencies:
|
||||
tr46: 0.0.3
|
||||
webidl-conversions: 3.0.1
|
||||
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
- "packages/*"
|
||||
Reference in New Issue
Block a user