tablinum 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -547,30 +547,6 @@ function resolveWinner(existing, incoming) {
547
547
  function isPlainObject(value) {
548
548
  return value !== null && typeof value === "object" && !Array.isArray(value);
549
549
  }
550
- function deepDiff(before, after) {
551
- const result = {};
552
- let hasChanges = false;
553
- for (const key of Object.keys(after)) {
554
- const a = before[key];
555
- const b = after[key];
556
- if (isPlainObject(a) && isPlainObject(b)) {
557
- const nested = deepDiff(a, b);
558
- if (nested !== null) {
559
- result[key] = nested;
560
- hasChanges = true;
561
- }
562
- } else if (Array.isArray(a) && Array.isArray(b)) {
563
- if (JSON.stringify(a) !== JSON.stringify(b)) {
564
- result[key] = b;
565
- hasChanges = true;
566
- }
567
- } else if (a !== b) {
568
- result[key] = b;
569
- hasChanges = true;
570
- }
571
- }
572
- return hasChanges ? result : null;
573
- }
574
550
  function deepMerge(target, source) {
575
551
  const result = { ...target };
576
552
  for (const key of Object.keys(source)) {
@@ -1056,13 +1032,12 @@ function createCollectionHandle(def, storage, watchCtx, validator, partialValida
1056
1032
  const { _d, _u, _a, _e, ...existingFields } = existing;
1057
1033
  const merged = { ...existingFields, ...data, id };
1058
1034
  yield* validator(merged);
1059
- const diff = deepDiff(existingFields, merged);
1060
1035
  const event = {
1061
1036
  id: makeEventId(),
1062
1037
  collection: collectionName,
1063
1038
  recordId: id,
1064
1039
  kind: "u",
1065
- data: diff ?? { id },
1040
+ data: merged,
1066
1041
  createdAt: Date.now(),
1067
1042
  author: localAuthor
1068
1043
  };
@@ -1070,7 +1045,7 @@ function createCollectionHandle(def, storage, watchCtx, validator, partialValida
1070
1045
  yield* Effect7.logDebug("Record updated", {
1071
1046
  collection: collectionName,
1072
1047
  recordId: id,
1073
- data: diff
1048
+ data: merged
1074
1049
  });
1075
1050
  yield* pruneEvents(storage, collectionName, id, def.eventRetention);
1076
1051
  })
@@ -2227,6 +2202,20 @@ function fetchAuthorProfile(relay, relayUrls, pubkey) {
2227
2202
  });
2228
2203
  }
2229
2204
 
2205
+ // src/sync/deletion.ts
2206
+ import { finalizeEvent } from "nostr-tools/pure";
2207
+ function createDeletionEvent(targetEventIds, signingKey) {
2208
+ return finalizeEvent(
2209
+ {
2210
+ kind: 5,
2211
+ content: "",
2212
+ tags: targetEventIds.map((id) => ["e", id]),
2213
+ created_at: Math.floor(Date.now() / 1e3)
2214
+ },
2215
+ signingKey
2216
+ );
2217
+ }
2218
+
2230
2219
  // src/services/Identity.ts
2231
2220
  import { ServiceMap as ServiceMap3 } from "effect";
2232
2221
  var Identity = class extends ServiceMap3.Service()("tablinum/Identity") {
@@ -2916,6 +2905,12 @@ var TablinumLive = Layer9.effect(
2916
2905
  }
2917
2906
  const gw = wrapResult.success;
2918
2907
  yield* storage.putGiftWrap({ id: gw.id, event: gw, createdAt: gw.created_at });
2908
+ const metaKey = `gw_record:${event.collection}:${event.recordId}`;
2909
+ const prevMapping = yield* storage.getMeta(metaKey).pipe(
2910
+ Effect21.orElseSucceed(() => void 0)
2911
+ );
2912
+ const epochPubKey = gw.tags.find((t) => t[0] === "p")?.[1];
2913
+ yield* storage.putMeta(metaKey, { gwId: gw.id, epochPubKey });
2919
2914
  yield* Effect21.forkIn(
2920
2915
  Effect21.gen(function* () {
2921
2916
  const publishResult = yield* Effect21.result(
@@ -2928,10 +2923,80 @@ var TablinumLive = Layer9.effect(
2928
2923
  if (publishResult._tag === "Failure") {
2929
2924
  reportSyncError(config.onSyncError, publishResult.failure);
2930
2925
  }
2926
+ if (prevMapping?.epochPubKey) {
2927
+ const signingKey = getDecryptionKey(epochStore, prevMapping.epochPubKey);
2928
+ if (signingKey) {
2929
+ const deletionEvent = createDeletionEvent(
2930
+ [prevMapping.gwId],
2931
+ signingKey
2932
+ );
2933
+ yield* relay.publish(deletionEvent, [...config.relays]).pipe(
2934
+ Effect21.tapError(
2935
+ (e) => Effect21.sync(() => reportSyncError(config.onSyncError, e))
2936
+ ),
2937
+ Effect21.ignore
2938
+ );
2939
+ }
2940
+ yield* storage.deleteGiftWrap(prevMapping.gwId).pipe(Effect21.ignore);
2941
+ }
2931
2942
  }),
2932
2943
  scope
2933
2944
  );
2934
2945
  });
2946
+ const republishAllUnderCurrentEpoch = () => Effect21.gen(function* () {
2947
+ const oldGwDeletions = [];
2948
+ for (const [, def] of allSchemaEntries) {
2949
+ const collectionName = def.name;
2950
+ const allRecords = yield* storage.getAllRecords(collectionName);
2951
+ for (const record of allRecords) {
2952
+ const recordId = record.id;
2953
+ const { _d, _u, _a, _e, ...fields } = record;
2954
+ const content = _d ? JSON.stringify(null) : JSON.stringify(fields);
2955
+ const dTag = `${collectionName}:${recordId}`;
2956
+ const wrapResult = yield* Effect21.result(
2957
+ giftWrap.wrap({
2958
+ kind: 1,
2959
+ content,
2960
+ tags: [["d", dTag]],
2961
+ created_at: Math.floor(Date.now() / 1e3)
2962
+ })
2963
+ );
2964
+ if (wrapResult._tag === "Failure") continue;
2965
+ const gw = wrapResult.success;
2966
+ yield* storage.putGiftWrap({ id: gw.id, event: gw, createdAt: gw.created_at });
2967
+ const metaKey = `gw_record:${collectionName}:${recordId}`;
2968
+ const prevMapping = yield* storage.getMeta(metaKey).pipe(
2969
+ Effect21.orElseSucceed(() => void 0)
2970
+ );
2971
+ if (prevMapping) oldGwDeletions.push(prevMapping);
2972
+ const epochPubKey = gw.tags.find((t) => t[0] === "p")?.[1];
2973
+ yield* storage.putMeta(metaKey, { gwId: gw.id, epochPubKey });
2974
+ yield* relay.publish(gw, [...config.relays]).pipe(
2975
+ Effect21.tapError((e) => Effect21.sync(() => reportSyncError(config.onSyncError, e))),
2976
+ Effect21.ignore
2977
+ );
2978
+ }
2979
+ }
2980
+ const byEpoch = /* @__PURE__ */ new Map();
2981
+ for (const { gwId, epochPubKey } of oldGwDeletions) {
2982
+ const ids = byEpoch.get(epochPubKey) ?? [];
2983
+ ids.push(gwId);
2984
+ byEpoch.set(epochPubKey, ids);
2985
+ }
2986
+ for (const [epochPubKey, gwIds] of byEpoch) {
2987
+ const signingKey = getDecryptionKey(epochStore, epochPubKey);
2988
+ if (signingKey) {
2989
+ const deletionEvent = createDeletionEvent(gwIds, signingKey);
2990
+ yield* relay.publish(deletionEvent, [...config.relays]).pipe(
2991
+ Effect21.tapError((e) => Effect21.sync(() => reportSyncError(config.onSyncError, e))),
2992
+ Effect21.ignore
2993
+ );
2994
+ }
2995
+ for (const gwId of gwIds) {
2996
+ yield* storage.deleteGiftWrap(gwId).pipe(Effect21.ignore);
2997
+ }
2998
+ }
2999
+ });
2935
3000
  const knownAuthors = /* @__PURE__ */ new Set();
2936
3001
  const putMemberRecord = (record) => Effect21.gen(function* () {
2937
3002
  const existing = yield* storage.getRecord("_members", record.id);
@@ -3019,6 +3084,13 @@ var TablinumLive = Layer9.effect(
3019
3084
  addedInEpoch: getCurrentEpoch(epochStore).id
3020
3085
  });
3021
3086
  }
3087
+ const migrated = yield* storage.getMeta("migration_gw_republish").pipe(
3088
+ Effect21.orElseSucceed(() => void 0)
3089
+ );
3090
+ if (!migrated) {
3091
+ yield* republishAllUnderCurrentEpoch().pipe(Effect21.ignore);
3092
+ yield* storage.putMeta("migration_gw_republish", true);
3093
+ }
3022
3094
  const withLog = (effect) => Effect21.provideService(effect, References3.MinimumLogLevel, config.logLevel);
3023
3095
  const ensureOpen = (effect) => withLog(
3024
3096
  Effect21.gen(function* () {
@@ -3098,6 +3170,7 @@ var TablinumLive = Layer9.effect(
3098
3170
  removedAt: Date.now(),
3099
3171
  removedInEpoch: result.epoch.id
3100
3172
  });
3173
+ yield* republishAllUnderCurrentEpoch();
3101
3174
  yield* Effect21.forEach(
3102
3175
  result.wrappedEvents,
3103
3176
  (wrappedEvent) => relay.publish(wrappedEvent, [...config.relays]).pipe(
@@ -3172,6 +3245,7 @@ var TablinumLive = Layer9.effect(
3172
3245
  removedAt: Date.now(),
3173
3246
  removedInEpoch: result.epoch.id
3174
3247
  });
3248
+ yield* republishAllUnderCurrentEpoch();
3175
3249
  yield* Effect21.forEach(
3176
3250
  result.wrappedEvents,
3177
3251
  (wrappedEvent) => relay.publish(wrappedEvent, [...config.relays]).pipe(
@@ -581,30 +581,6 @@ function resolveWinner(existing, incoming) {
581
581
  function isPlainObject(value) {
582
582
  return value !== null && typeof value === "object" && !Array.isArray(value);
583
583
  }
584
- function deepDiff(before, after) {
585
- const result = {};
586
- let hasChanges = false;
587
- for (const key of Object.keys(after)) {
588
- const a = before[key];
589
- const b = after[key];
590
- if (isPlainObject(a) && isPlainObject(b)) {
591
- const nested = deepDiff(a, b);
592
- if (nested !== null) {
593
- result[key] = nested;
594
- hasChanges = true;
595
- }
596
- } else if (Array.isArray(a) && Array.isArray(b)) {
597
- if (JSON.stringify(a) !== JSON.stringify(b)) {
598
- result[key] = b;
599
- hasChanges = true;
600
- }
601
- } else if (a !== b) {
602
- result[key] = b;
603
- hasChanges = true;
604
- }
605
- }
606
- return hasChanges ? result : null;
607
- }
608
584
  function deepMerge(target, source) {
609
585
  const result = { ...target };
610
586
  for (const key of Object.keys(source)) {
@@ -1090,13 +1066,12 @@ function createCollectionHandle(def, storage, watchCtx, validator, partialValida
1090
1066
  const { _d, _u, _a, _e, ...existingFields } = existing;
1091
1067
  const merged = { ...existingFields, ...data, id };
1092
1068
  yield* validator(merged);
1093
- const diff = deepDiff(existingFields, merged);
1094
1069
  const event = {
1095
1070
  id: makeEventId(),
1096
1071
  collection: collectionName,
1097
1072
  recordId: id,
1098
1073
  kind: "u",
1099
- data: diff ?? { id },
1074
+ data: merged,
1100
1075
  createdAt: Date.now(),
1101
1076
  author: localAuthor
1102
1077
  };
@@ -1104,7 +1079,7 @@ function createCollectionHandle(def, storage, watchCtx, validator, partialValida
1104
1079
  yield* Effect7.logDebug("Record updated", {
1105
1080
  collection: collectionName,
1106
1081
  recordId: id,
1107
- data: diff
1082
+ data: merged
1108
1083
  });
1109
1084
  yield* pruneEvents(storage, collectionName, id, def.eventRetention);
1110
1085
  })
@@ -2261,6 +2236,20 @@ function fetchAuthorProfile(relay, relayUrls, pubkey) {
2261
2236
  });
2262
2237
  }
2263
2238
 
2239
+ // src/sync/deletion.ts
2240
+ import { finalizeEvent } from "nostr-tools/pure";
2241
+ function createDeletionEvent(targetEventIds, signingKey) {
2242
+ return finalizeEvent(
2243
+ {
2244
+ kind: 5,
2245
+ content: "",
2246
+ tags: targetEventIds.map((id) => ["e", id]),
2247
+ created_at: Math.floor(Date.now() / 1e3)
2248
+ },
2249
+ signingKey
2250
+ );
2251
+ }
2252
+
2264
2253
  // src/services/Identity.ts
2265
2254
  import { ServiceMap as ServiceMap3 } from "effect";
2266
2255
  var Identity = class extends ServiceMap3.Service()("tablinum/Identity") {
@@ -2950,6 +2939,12 @@ var TablinumLive = Layer9.effect(
2950
2939
  }
2951
2940
  const gw = wrapResult.success;
2952
2941
  yield* storage.putGiftWrap({ id: gw.id, event: gw, createdAt: gw.created_at });
2942
+ const metaKey = `gw_record:${event.collection}:${event.recordId}`;
2943
+ const prevMapping = yield* storage.getMeta(metaKey).pipe(
2944
+ Effect21.orElseSucceed(() => void 0)
2945
+ );
2946
+ const epochPubKey = gw.tags.find((t) => t[0] === "p")?.[1];
2947
+ yield* storage.putMeta(metaKey, { gwId: gw.id, epochPubKey });
2953
2948
  yield* Effect21.forkIn(
2954
2949
  Effect21.gen(function* () {
2955
2950
  const publishResult = yield* Effect21.result(
@@ -2962,10 +2957,80 @@ var TablinumLive = Layer9.effect(
2962
2957
  if (publishResult._tag === "Failure") {
2963
2958
  reportSyncError(config.onSyncError, publishResult.failure);
2964
2959
  }
2960
+ if (prevMapping?.epochPubKey) {
2961
+ const signingKey = getDecryptionKey(epochStore, prevMapping.epochPubKey);
2962
+ if (signingKey) {
2963
+ const deletionEvent = createDeletionEvent(
2964
+ [prevMapping.gwId],
2965
+ signingKey
2966
+ );
2967
+ yield* relay.publish(deletionEvent, [...config.relays]).pipe(
2968
+ Effect21.tapError(
2969
+ (e) => Effect21.sync(() => reportSyncError(config.onSyncError, e))
2970
+ ),
2971
+ Effect21.ignore
2972
+ );
2973
+ }
2974
+ yield* storage.deleteGiftWrap(prevMapping.gwId).pipe(Effect21.ignore);
2975
+ }
2965
2976
  }),
2966
2977
  scope
2967
2978
  );
2968
2979
  });
2980
+ const republishAllUnderCurrentEpoch = () => Effect21.gen(function* () {
2981
+ const oldGwDeletions = [];
2982
+ for (const [, def] of allSchemaEntries) {
2983
+ const collectionName = def.name;
2984
+ const allRecords = yield* storage.getAllRecords(collectionName);
2985
+ for (const record of allRecords) {
2986
+ const recordId = record.id;
2987
+ const { _d, _u, _a, _e, ...fields } = record;
2988
+ const content = _d ? JSON.stringify(null) : JSON.stringify(fields);
2989
+ const dTag = `${collectionName}:${recordId}`;
2990
+ const wrapResult = yield* Effect21.result(
2991
+ giftWrap.wrap({
2992
+ kind: 1,
2993
+ content,
2994
+ tags: [["d", dTag]],
2995
+ created_at: Math.floor(Date.now() / 1e3)
2996
+ })
2997
+ );
2998
+ if (wrapResult._tag === "Failure") continue;
2999
+ const gw = wrapResult.success;
3000
+ yield* storage.putGiftWrap({ id: gw.id, event: gw, createdAt: gw.created_at });
3001
+ const metaKey = `gw_record:${collectionName}:${recordId}`;
3002
+ const prevMapping = yield* storage.getMeta(metaKey).pipe(
3003
+ Effect21.orElseSucceed(() => void 0)
3004
+ );
3005
+ if (prevMapping) oldGwDeletions.push(prevMapping);
3006
+ const epochPubKey = gw.tags.find((t) => t[0] === "p")?.[1];
3007
+ yield* storage.putMeta(metaKey, { gwId: gw.id, epochPubKey });
3008
+ yield* relay.publish(gw, [...config.relays]).pipe(
3009
+ Effect21.tapError((e) => Effect21.sync(() => reportSyncError(config.onSyncError, e))),
3010
+ Effect21.ignore
3011
+ );
3012
+ }
3013
+ }
3014
+ const byEpoch = /* @__PURE__ */ new Map();
3015
+ for (const { gwId, epochPubKey } of oldGwDeletions) {
3016
+ const ids = byEpoch.get(epochPubKey) ?? [];
3017
+ ids.push(gwId);
3018
+ byEpoch.set(epochPubKey, ids);
3019
+ }
3020
+ for (const [epochPubKey, gwIds] of byEpoch) {
3021
+ const signingKey = getDecryptionKey(epochStore, epochPubKey);
3022
+ if (signingKey) {
3023
+ const deletionEvent = createDeletionEvent(gwIds, signingKey);
3024
+ yield* relay.publish(deletionEvent, [...config.relays]).pipe(
3025
+ Effect21.tapError((e) => Effect21.sync(() => reportSyncError(config.onSyncError, e))),
3026
+ Effect21.ignore
3027
+ );
3028
+ }
3029
+ for (const gwId of gwIds) {
3030
+ yield* storage.deleteGiftWrap(gwId).pipe(Effect21.ignore);
3031
+ }
3032
+ }
3033
+ });
2969
3034
  const knownAuthors = /* @__PURE__ */ new Set();
2970
3035
  const putMemberRecord = (record) => Effect21.gen(function* () {
2971
3036
  const existing = yield* storage.getRecord("_members", record.id);
@@ -3053,6 +3118,13 @@ var TablinumLive = Layer9.effect(
3053
3118
  addedInEpoch: getCurrentEpoch(epochStore).id
3054
3119
  });
3055
3120
  }
3121
+ const migrated = yield* storage.getMeta("migration_gw_republish").pipe(
3122
+ Effect21.orElseSucceed(() => void 0)
3123
+ );
3124
+ if (!migrated) {
3125
+ yield* republishAllUnderCurrentEpoch().pipe(Effect21.ignore);
3126
+ yield* storage.putMeta("migration_gw_republish", true);
3127
+ }
3056
3128
  const withLog = (effect) => Effect21.provideService(effect, References3.MinimumLogLevel, config.logLevel);
3057
3129
  const ensureOpen = (effect) => withLog(
3058
3130
  Effect21.gen(function* () {
@@ -3132,6 +3204,7 @@ var TablinumLive = Layer9.effect(
3132
3204
  removedAt: Date.now(),
3133
3205
  removedInEpoch: result.epoch.id
3134
3206
  });
3207
+ yield* republishAllUnderCurrentEpoch();
3135
3208
  yield* Effect21.forEach(
3136
3209
  result.wrappedEvents,
3137
3210
  (wrappedEvent) => relay.publish(wrappedEvent, [...config.relays]).pipe(
@@ -3206,6 +3279,7 @@ var TablinumLive = Layer9.effect(
3206
3279
  removedAt: Date.now(),
3207
3280
  removedInEpoch: result.epoch.id
3208
3281
  });
3282
+ yield* republishAllUnderCurrentEpoch();
3209
3283
  yield* Effect21.forEach(
3210
3284
  result.wrappedEvents,
3211
3285
  (wrappedEvent) => relay.publish(wrappedEvent, [...config.relays]).pipe(
@@ -0,0 +1,10 @@
1
+ import type { NostrEvent } from "nostr-tools/pure";
2
+ /**
3
+ * Creates a NIP-09 deletion request (kind 5) for one or more events.
4
+ *
5
+ * For gift wraps (kind 1059), NIP-59 specifies that relays SHOULD honor
6
+ * deletions where the signer's pubkey matches the gift wrap's p-tag.
7
+ * This means the epoch key holder can delete any gift wrap addressed
8
+ * to that epoch — regardless of who originally created it.
9
+ */
10
+ export declare function createDeletionEvent(targetEventIds: readonly string[], signingKey: Uint8Array): NostrEvent;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tablinum",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/kevmodrome/tablinum.git",