@kubun/plugin-p2p 0.16.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -536,6 +536,14 @@ export function createGroupContext(ctx, deps) {
536
536
  createCatalog: async (p)=>{
537
537
  const graphStore = await getGraphStore();
538
538
  const id = p.catalogID ?? deps.runtime.getRandomID();
539
+ // Idempotent when the caller pins the id: a crashed flow (e.g. a device
540
+ // join) recreates its own catalog deterministically on retry. `catalog:set`
541
+ // is replace-by-construction, so a re-create against an existing pinned id
542
+ // returns the stored catalog instead of failing a primary-key insert.
543
+ if (p.catalogID != null) {
544
+ const existing = await graphStore.getCatalog(id);
545
+ if (existing != null) return toCatalogData(existing);
546
+ }
539
547
  const hlcStr = HLC.serialize(deps.hlc.now());
540
548
  const filterCriteria = JSON.parse(p.filterCriteria);
541
549
  // Sign a self-contained `catalog:set` token at creation so the catalog can
@@ -40,6 +40,33 @@ export function createJoinContext(ctx, deps) {
40
40
  const p2pStore = await getP2PStore(deps.stores);
41
41
  const stored = await p2pStore.getPendingJoinRequest(ctx.viewerDID);
42
42
  if (stored == null) {
43
+ // Idempotent by construction: completing a join is not atomic with the
44
+ // caller's own progress record (the offline exchange routinely spans two
45
+ // processes), so a caller that crashes after a successful completion
46
+ // retries with the same payload. A successful completion consumes the
47
+ // pending request at its end, so "no pending request" AND already an
48
+ // active member of the invited group means this exact join already
49
+ // landed — return it instead of failing. A fresh rejoin/re-admit always
50
+ // carries a new pending request (a new prepareJoinRequest), so it never
51
+ // takes this path; a genuinely bogus payload either fails to decode or
52
+ // leaves the device a non-member, so it still errors below.
53
+ let replayed;
54
+ try {
55
+ replayed = decodeInvitePayload(invitePayloadStr);
56
+ } catch {
57
+ replayed = undefined;
58
+ }
59
+ if (replayed != null) {
60
+ const existing = await p2pStore.getGroup(replayed.groupID);
61
+ if (existing?.status === 'joined') {
62
+ return await finalizeJoinedGroup({
63
+ stores: deps.stores,
64
+ emitter: deps.emitter,
65
+ invite: replayed,
66
+ logger: deps.logger
67
+ });
68
+ }
69
+ }
43
70
  // Naming the way out matters: "no pending join request" reads to an
44
71
  // operator as "the payload you were handed is bad", and they go asking
45
72
  // for a new invite against key material that was never the problem.
@@ -80,8 +80,8 @@ import { toISO } from './types.js';
80
80
  // The version and variant nibbles are overwritten rather than derived, so the
81
81
  // string is a well-formed UUID that carries 122 bits of the digest.
82
82
  const bytes = Uint8Array.from(digest);
83
- bytes[6] = bytes[6] & 0x0f | 0x40;
84
- bytes[8] = bytes[8] & 0x3f | 0x80;
83
+ bytes[6] = (bytes[6] ?? 0) & 0x0f | 0x40;
84
+ bytes[8] = (bytes[8] ?? 0) & 0x3f | 0x80;
85
85
  const hex = Array.from(bytes, (b)=>b.toString(16).padStart(2, '0')).join('');
86
86
  return [
87
87
  hex.slice(0, 8),
@@ -132,7 +132,7 @@ export function createSyncContext(_ctx, deps) {
132
132
  const counts = await Promise.all(uniqueOwners.map((owner)=>store.listProvisioningExpectations({
133
133
  ownerDID: owner
134
134
  })));
135
- const withExpectation = uniqueOwners.filter((_owner, index)=>counts[index].length > 0);
135
+ const withExpectation = uniqueOwners.filter((_owner, index)=>(counts[index]?.length ?? 0) > 0);
136
136
  if (withExpectation.length === 0) {
137
137
  return [];
138
138
  }
@@ -386,16 +386,18 @@ function rethrowHubError(error) {
386
386
  this.#logger?.debug('hub-like open reached no hub', {
387
387
  topics: topics.length
388
388
  });
389
- throw first.reason ?? new Error('hub did not answer any subscribe');
389
+ throw first?.reason ?? new Error('hub did not answer any subscribe');
390
390
  }
391
391
  this.#proven = true;
392
392
  const failed = [];
393
- for(let i = 0; i < results.length; i++){
394
- const result = results[i];
393
+ for (const [i, result] of results.entries()){
395
394
  if (result.status !== 'rejected') {
396
395
  continue;
397
396
  }
398
397
  const topicID = topics[i];
398
+ if (topicID == null) {
399
+ continue;
400
+ }
399
401
  const error = asHubError(result.reason);
400
402
  this.#logger?.warn('hub-like re-subscribe failed', {
401
403
  topicID,
@@ -444,10 +446,9 @@ function rethrowHubError(error) {
444
446
  }
445
447
  })));
446
448
  const stillFailing = [];
447
- for(let i = 0; i < results.length; i++){
448
- const result = results[i];
449
+ for (const [i, result] of results.entries()){
449
450
  const topicID = pending[i];
450
- if (result.status !== 'rejected') {
451
+ if (topicID == null || result.status !== 'rejected') {
451
452
  continue;
452
453
  }
453
454
  if (isPermanentSubscribeFailure(asHubError(result.reason))) {
package/lib/schema.js CHANGED
@@ -726,7 +726,7 @@ extend type Mutation {
726
726
  createGroup(input: CreateGroupInput!): CreateGroupPayload!
727
727
  requestCreateCircle(input: RequestCreateCircleInput!): RequestCreateCirclePayload!
728
728
  requestAddCircleMember(input: RequestAddCircleMemberInput!): RequestAddCircleMemberPayload!
729
- createCatalog(name: String!, description: String, filterCriteria: String!): PeerCatalog!
729
+ createCatalog(catalogID: ID, name: String!, description: String, filterCriteria: String!): PeerCatalog!
730
730
  requestInviteToGroup(input: RequestInviteToGroupInput!): RequestInviteToGroupPayload!
731
731
  joinGroup(invitePayload: String!, joinRequestPayload: String!): JoinGroupPayload!
732
732
  prepareJoinRequest: PrepareJoinRequestPayload!
@@ -1101,6 +1101,7 @@ export function createP2PSchemaExtension(emitter, syncManager, logger = getKubun
1101
1101
  },
1102
1102
  createCatalog: async (_source, args, context)=>{
1103
1103
  return await requireP2P(context).group.createCatalog({
1104
+ catalogID: args.catalogID,
1104
1105
  name: args.name,
1105
1106
  description: args.description,
1106
1107
  filterCriteria: args.filterCriteria
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubun/plugin-p2p",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
4
4
  "license": "see LICENSE.md",
5
5
  "sideEffects": false,
6
6
  "type": "module",
@@ -91,8 +91,8 @@
91
91
  "scripts": {
92
92
  "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
93
93
  "build:clean": "del lib",
94
- "build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
95
- "build:types": "tsc --emitDeclarationOnly --skipLibCheck",
94
+ "build:js": "del 'lib/**/*.js' && swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
95
+ "build:types": "del 'lib/**/*.d.ts' 'lib/**/*.d.ts.map' && tsc --emitDeclarationOnly --skipLibCheck",
96
96
  "test": "pnpm run test:types && pnpm run test:unit",
97
97
  "test:types": "tsc --noEmit -p tsconfig.test.json",
98
98
  "test:unit": "vitest run"