birdclaw 0.9.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 0.9.2 - 2026-07-04
4
+
5
+ ### Fixed
6
+
7
+ - Split oversized backup JSONL outputs into deterministic 48 MiB part files and serialize and validate them sequentially, keeping large archives below hosted Git limits without Git LFS.
8
+
3
9
  ## 0.9.1 - 2026-07-02
4
10
 
5
11
  ### Added
package/README.md CHANGED
@@ -598,9 +598,11 @@ data/dms/conversations.jsonl
598
598
  data/dms/YYYY.jsonl
599
599
  data/moderation/blocks.jsonl
600
600
  data/moderation/mutes.jsonl
601
+ data/<logical-shard>.part-NNNN.jsonl
601
602
  ```
602
603
 
603
604
  Tweets are sharded by year for human browsing and yearly analysis. Collection-only tweets whose real creation date is unknown go into `data/tweets/unknown.jsonl` instead of pretending they belong to 1970. Timeline membership is stored in `data/timeline_edges`; likes and bookmarks are stored as account-scoped collection edges in `data/collections`. DMs are sharded by year with `conversation_id` in each row; this keeps Git fast while preserving conversation membership.
605
+ Any logical shard larger than 48 MiB is split into deterministic `.part-0001.jsonl` files. Import and validation accept both numbered parts and the earlier unsplit layout, so large private backups remain ordinary Git repositories without Git LFS.
604
606
 
605
607
  Use `backup sync` when the target is a private Git repo. It pulls first, merge-imports the remote backup into local SQLite, exports the local union back into text shards, commits, and pushes.
606
608
 
@@ -187,6 +187,10 @@ var __test__ = {
187
187
  };
188
188
 
189
189
  // src/lib/backup-table-codecs.ts
190
+ var BACKUP_SHARD_PART_PATTERN = /\.part-\d{4,}\.jsonl$/u;
191
+ function logicalBackupShardPath(relativePath) {
192
+ return relativePath.replace(BACKUP_SHARD_PART_PATTERN, ".jsonl");
193
+ }
190
194
  function fixedShard(relativePath, countKey) {
191
195
  return {
192
196
  shardPath: () => relativePath,
@@ -1205,7 +1209,8 @@ function adaptLegacyTweetState(schemaVersion, tweets, collections, timelineEdges
1205
1209
  return { collections: nextCollections, timelineEdges: nextTimelineEdges };
1206
1210
  }
1207
1211
  function backupCodecForPath(relativePath, codecs = BACKUP_TABLE_CODECS) {
1208
- const matches = codecs.filter((codec) => codec.matchesPath(relativePath));
1212
+ const logicalPath = logicalBackupShardPath(relativePath);
1213
+ const matches = codecs.filter((codec) => codec.matchesPath(logicalPath));
1209
1214
  if (matches.length !== 1) {
1210
1215
  throw new Error(
1211
1216
  matches.length === 0 ? `No backup codec owns path: ${relativePath}` : `Multiple backup codecs own path: ${relativePath}`
@@ -1234,7 +1239,7 @@ function countBackupFiles(files, codecs = BACKUP_TABLE_CODECS) {
1234
1239
  for (const file of files) {
1235
1240
  if (!file.path.startsWith("data/")) continue;
1236
1241
  const codec = backupCodecForPath(file.path, codecs);
1237
- const key = codec.countKey(file.path);
1242
+ const key = codec.countKey(logicalBackupShardPath(file.path));
1238
1243
  counts[key] = (counts[key] ?? 0) + file.rows;
1239
1244
  }
1240
1245
  return counts;
@@ -3716,8 +3721,9 @@ function runSubprocessEffect(options) {
3716
3721
  }
3717
3722
 
3718
3723
  // src/lib/backup.ts
3719
- var BACKUP_SCHEMA_VERSION = 3;
3724
+ var BACKUP_SCHEMA_VERSION = 4;
3720
3725
  var MIN_SUPPORTED_BACKUP_SCHEMA_VERSION = 1;
3726
+ var DEFAULT_MAX_BACKUP_SHARD_BYTES = 48 * 1024 * 1024;
3721
3727
  var MANIFEST_PATH = "manifest.json";
3722
3728
  var DATA_DIR = "data";
3723
3729
  var GITATTRIBUTES_PATH = ".gitattributes";
@@ -3815,6 +3821,50 @@ function getExportRowSets(db) {
3815
3821
  function buildShards(db) {
3816
3822
  return buildBackupShardsFromRowSets(getExportRowSets(db));
3817
3823
  }
3824
+ function normalizeMaxBackupShardBytes(value) {
3825
+ const maxBytes = value ?? DEFAULT_MAX_BACKUP_SHARD_BYTES;
3826
+ if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
3827
+ throw new Error("Backup shard byte limit must be a positive integer");
3828
+ }
3829
+ return maxBytes;
3830
+ }
3831
+ function partPath(relativePath, partNumber) {
3832
+ if (!relativePath.endsWith(".jsonl")) {
3833
+ throw new Error(`Backup shard path must end in .jsonl: ${relativePath}`);
3834
+ }
3835
+ return relativePath.replace(
3836
+ /\.jsonl$/u,
3837
+ `.part-${String(partNumber).padStart(4, "0")}.jsonl`
3838
+ );
3839
+ }
3840
+ function splitJsonlShard(relativePath, rows, maxBytes) {
3841
+ const parts = [];
3842
+ let currentRows = [];
3843
+ let currentBytes = 0;
3844
+ for (const [index, row] of rows.entries()) {
3845
+ const rowBytes = Buffer.byteLength(jsonlStringify(row)) + 1;
3846
+ if (rowBytes > maxBytes) {
3847
+ throw new Error(
3848
+ `Backup row exceeds shard byte limit: ${relativePath}:${index + 1} is ${rowBytes} bytes (limit ${maxBytes})`
3849
+ );
3850
+ }
3851
+ if (currentRows.length > 0 && currentBytes + rowBytes > maxBytes) {
3852
+ parts.push(currentRows);
3853
+ currentRows = [];
3854
+ currentBytes = 0;
3855
+ }
3856
+ currentRows.push(row);
3857
+ currentBytes += rowBytes;
3858
+ }
3859
+ if (currentRows.length > 0) parts.push(currentRows);
3860
+ if (parts.length <= 1) {
3861
+ return [{ relativePath, rows: parts[0] ?? [] }];
3862
+ }
3863
+ return parts.map((partRows, index) => ({
3864
+ relativePath: partPath(relativePath, index + 1),
3865
+ rows: partRows
3866
+ }));
3867
+ }
3818
3868
  function writeJsonlFileEffect(repoPath, relativePath, rows) {
3819
3869
  return Effect5.gen(function* () {
3820
3870
  const fullPath = yield* trySync2(
@@ -3951,6 +4001,7 @@ data/follow_events.jsonl
3951
4001
  \`\`\`
3952
4002
 
3953
4003
  Tweets are sharded by creation year. Collection-only tweets whose creation date is unknown live in \`data/tweets/unknown.jsonl\`. Timeline edges keep account-scoped home/mention membership separate from canonical tweet content. DMs are sharded by year and keep \`conversation_id\` in each row.
4004
+ Logical shards larger than 48 MiB are split into deterministic \`.part-0001.jsonl\` files so ordinary Git hosting remains usable without Git LFS.
3954
4005
  The links shard stores expanded short URLs and their source tweet/DM occurrences so linked-tweet search can be rebuilt without re-expanding every \`t.co\` URL.
3955
4006
 
3956
4007
  Never commit live tokens, browser cookies, raw SQLite WAL/SHM sidecars, or temporary cache files here.
@@ -4202,7 +4253,8 @@ function exportBackupEffect({
4202
4253
  commit = false,
4203
4254
  push = false,
4204
4255
  message = "archive: update birdclaw backup",
4205
- validate = true
4256
+ validate = true,
4257
+ maxShardBytes
4206
4258
  }) {
4207
4259
  return Effect5.gen(function* () {
4208
4260
  const resolvedRepoPath = yield* trySync2(() => path2.resolve(repoPath));
@@ -4216,19 +4268,27 @@ function exportBackupEffect({
4216
4268
  }
4217
4269
  yield* ensureBackupGitattributesEffect(resolvedRepoPath);
4218
4270
  yield* ensureBackupReadmeEffect(resolvedRepoPath);
4271
+ const maxBytes = yield* trySync2(
4272
+ () => normalizeMaxBackupShardBytes(maxShardBytes)
4273
+ );
4219
4274
  const shards = yield* trySync2(() => buildShards(database));
4220
4275
  const shardEntries = yield* trySync2(
4221
4276
  () => [...shards.entries()].sort(
4222
4277
  ([left], [right]) => left.localeCompare(right)
4223
4278
  )
4224
4279
  );
4280
+ const shardParts = yield* trySync2(
4281
+ () => shardEntries.flatMap(
4282
+ ([relativePath, rows]) => splitJsonlShard(relativePath, rows, maxBytes)
4283
+ )
4284
+ );
4225
4285
  const expectedPaths = yield* trySync2(
4226
- () => new Set(shardEntries.map(([relativePath]) => relativePath))
4286
+ () => new Set(shardParts.map(({ relativePath }) => relativePath))
4227
4287
  );
4228
4288
  const files = yield* Effect5.forEach(
4229
- shardEntries,
4230
- ([relativePath, rows]) => writeJsonlFileEffect(resolvedRepoPath, relativePath, rows),
4231
- { concurrency: "unbounded" }
4289
+ shardParts,
4290
+ ({ relativePath, rows }) => writeJsonlFileEffect(resolvedRepoPath, relativePath, rows),
4291
+ { concurrency: 1 }
4232
4292
  );
4233
4293
  yield* removeStaleBackupFilesEffect(resolvedRepoPath, expectedPaths);
4234
4294
  const counts = yield* trySync2(() => countBackupFiles(files));
@@ -4416,7 +4476,7 @@ function readBackupImportRowsEffect(resolvedRepoPath, manifest) {
4416
4476
  });
4417
4477
  }
4418
4478
  function rowsForManifestPath(manifest, predicate) {
4419
- return manifest.files.map((file) => file.path).filter(predicate).sort();
4479
+ return manifest.files.map((file) => file.path).filter((relativePath) => predicate(logicalBackupShardPath(relativePath))).sort();
4420
4480
  }
4421
4481
  function importBackupEffect({
4422
4482
  repoPath,
@@ -4886,7 +4946,7 @@ function validateBackupEffect(repoPath) {
4886
4946
  }
4887
4947
  return { file, errors: fileErrors };
4888
4948
  }),
4889
- { concurrency: "unbounded" }
4949
+ { concurrency: 1 }
4890
4950
  );
4891
4951
  const files = [];
4892
4952
  for (const result of results) {
@@ -2546,6 +2546,10 @@ var Route$17 = createFileRoute("/api/xurl-rate-limits")({ server: { handlers: {
2546
2546
  })) } } });
2547
2547
  //#endregion
2548
2548
  //#region src/lib/backup-table-codecs.ts
2549
+ var BACKUP_SHARD_PART_PATTERN = /\.part-\d{4,}\.jsonl$/u;
2550
+ function logicalBackupShardPath(relativePath) {
2551
+ return relativePath.replace(BACKUP_SHARD_PART_PATTERN, ".jsonl");
2552
+ }
2549
2553
  function fixedShard(relativePath, countKey) {
2550
2554
  return {
2551
2555
  shardPath: () => relativePath,
@@ -3567,7 +3571,8 @@ function adaptLegacyTweetState(schemaVersion, tweets, collections, timelineEdges
3567
3571
  };
3568
3572
  }
3569
3573
  function backupCodecForPath(relativePath, codecs = BACKUP_TABLE_CODECS) {
3570
- const matches = codecs.filter((codec) => codec.matchesPath(relativePath));
3574
+ const logicalPath = logicalBackupShardPath(relativePath);
3575
+ const matches = codecs.filter((codec) => codec.matchesPath(logicalPath));
3571
3576
  if (matches.length !== 1) throw new Error(matches.length === 0 ? `No backup codec owns path: ${relativePath}` : `Multiple backup codecs own path: ${relativePath}`);
3572
3577
  return matches[0];
3573
3578
  }
@@ -3590,7 +3595,7 @@ function countBackupFiles(files, codecs = BACKUP_TABLE_CODECS) {
3590
3595
  const counts = {};
3591
3596
  for (const file of files) {
3592
3597
  if (!file.path.startsWith("data/")) continue;
3593
- const key = backupCodecForPath(file.path, codecs).countKey(file.path);
3598
+ const key = backupCodecForPath(file.path, codecs).countKey(logicalBackupShardPath(file.path));
3594
3599
  counts[key] = (counts[key] ?? 0) + file.rows;
3595
3600
  }
3596
3601
  return counts;
@@ -3996,8 +4001,9 @@ function runSubprocessEffect(options) {
3996
4001
  }
3997
4002
  //#endregion
3998
4003
  //#region src/lib/backup.ts
3999
- var BACKUP_SCHEMA_VERSION = 3;
4004
+ var BACKUP_SCHEMA_VERSION = 4;
4000
4005
  var MIN_SUPPORTED_BACKUP_SCHEMA_VERSION = 1;
4006
+ var DEFAULT_MAX_BACKUP_SHARD_BYTES = 48 * 1024 * 1024;
4001
4007
  var MANIFEST_PATH = "manifest.json";
4002
4008
  var DATA_DIR = "data";
4003
4009
  var GITATTRIBUTES_PATH = ".gitattributes";
@@ -4080,6 +4086,40 @@ function getExportRowSets(db) {
4080
4086
  function buildShards(db) {
4081
4087
  return buildBackupShardsFromRowSets(getExportRowSets(db));
4082
4088
  }
4089
+ function normalizeMaxBackupShardBytes(value) {
4090
+ const maxBytes = value ?? DEFAULT_MAX_BACKUP_SHARD_BYTES;
4091
+ if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) throw new Error("Backup shard byte limit must be a positive integer");
4092
+ return maxBytes;
4093
+ }
4094
+ function partPath(relativePath, partNumber) {
4095
+ if (!relativePath.endsWith(".jsonl")) throw new Error(`Backup shard path must end in .jsonl: ${relativePath}`);
4096
+ return relativePath.replace(/\.jsonl$/u, `.part-${String(partNumber).padStart(4, "0")}.jsonl`);
4097
+ }
4098
+ function splitJsonlShard(relativePath, rows, maxBytes) {
4099
+ const parts = [];
4100
+ let currentRows = [];
4101
+ let currentBytes = 0;
4102
+ for (const [index, row] of rows.entries()) {
4103
+ const rowBytes = Buffer.byteLength(jsonlStringify(row)) + 1;
4104
+ if (rowBytes > maxBytes) throw new Error(`Backup row exceeds shard byte limit: ${relativePath}:${index + 1} is ${rowBytes} bytes (limit ${maxBytes})`);
4105
+ if (currentRows.length > 0 && currentBytes + rowBytes > maxBytes) {
4106
+ parts.push(currentRows);
4107
+ currentRows = [];
4108
+ currentBytes = 0;
4109
+ }
4110
+ currentRows.push(row);
4111
+ currentBytes += rowBytes;
4112
+ }
4113
+ if (currentRows.length > 0) parts.push(currentRows);
4114
+ if (parts.length <= 1) return [{
4115
+ relativePath,
4116
+ rows: parts[0] ?? []
4117
+ }];
4118
+ return parts.map((partRows, index) => ({
4119
+ relativePath: partPath(relativePath, index + 1),
4120
+ rows: partRows
4121
+ }));
4122
+ }
4083
4123
  function writeJsonlFileEffect(repoPath, relativePath, rows) {
4084
4124
  return Effect.gen(function* () {
4085
4125
  const fullPath = yield* trySync$10(() => resolveBackupFilePath(repoPath, relativePath));
@@ -4165,6 +4205,7 @@ data/follow_events.jsonl
4165
4205
  \`\`\`
4166
4206
 
4167
4207
  Tweets are sharded by creation year. Collection-only tweets whose creation date is unknown live in \`data/tweets/unknown.jsonl\`. Timeline edges keep account-scoped home/mention membership separate from canonical tweet content. DMs are sharded by year and keep \`conversation_id\` in each row.
4208
+ Logical shards larger than 48 MiB are split into deterministic \`.part-0001.jsonl\` files so ordinary Git hosting remains usable without Git LFS.
4168
4209
  The links shard stores expanded short URLs and their source tweet/DM occurrences so linked-tweet search can be rebuilt without re-expanding every \`t.co\` URL.
4169
4210
 
4170
4211
  Never commit live tokens, browser cookies, raw SQLite WAL/SHM sidecars, or temporary cache files here.
@@ -4398,7 +4439,7 @@ function pullBackupGitRepoEffect(repoPath) {
4398
4439
  ]).pipe(Effect.as(true), Effect.catchAll(() => Effect.succeed(false)))));
4399
4440
  });
4400
4441
  }
4401
- function exportBackupEffect({ repoPath, db, commit = false, push = false, message = "archive: update birdclaw backup", validate = true }) {
4442
+ function exportBackupEffect({ repoPath, db, commit = false, push = false, message = "archive: update birdclaw backup", validate = true, maxShardBytes }) {
4402
4443
  return Effect.gen(function* () {
4403
4444
  const resolvedRepoPath = yield* trySync$10(() => path.resolve(repoPath));
4404
4445
  const database = db ?? (yield* trySync$10(() => getNativeDb({ seedDemoData: false })));
@@ -4407,10 +4448,12 @@ function exportBackupEffect({ repoPath, db, commit = false, push = false, messag
4407
4448
  if (!repoStat.isDirectory() || repoStat.isSymbolicLink()) return yield* Effect.fail(/* @__PURE__ */ new Error("Backup repository path must be a real directory"));
4408
4449
  yield* ensureBackupGitattributesEffect(resolvedRepoPath);
4409
4450
  yield* ensureBackupReadmeEffect(resolvedRepoPath);
4451
+ const maxBytes = yield* trySync$10(() => normalizeMaxBackupShardBytes(maxShardBytes));
4410
4452
  const shards = yield* trySync$10(() => buildShards(database));
4411
4453
  const shardEntries = yield* trySync$10(() => [...shards.entries()].sort(([left], [right]) => left.localeCompare(right)));
4412
- const expectedPaths = yield* trySync$10(() => new Set(shardEntries.map(([relativePath]) => relativePath)));
4413
- const files = yield* Effect.forEach(shardEntries, ([relativePath, rows]) => writeJsonlFileEffect(resolvedRepoPath, relativePath, rows), { concurrency: "unbounded" });
4454
+ const shardParts = yield* trySync$10(() => shardEntries.flatMap(([relativePath, rows]) => splitJsonlShard(relativePath, rows, maxBytes)));
4455
+ const expectedPaths = yield* trySync$10(() => new Set(shardParts.map(({ relativePath }) => relativePath)));
4456
+ const files = yield* Effect.forEach(shardParts, ({ relativePath, rows }) => writeJsonlFileEffect(resolvedRepoPath, relativePath, rows), { concurrency: 1 });
4414
4457
  yield* removeStaleBackupFilesEffect(resolvedRepoPath, expectedPaths);
4415
4458
  const counts = yield* trySync$10(() => countBackupFiles(files));
4416
4459
  const backupHash = yield* trySync$10(() => computeBackupHash(files));
@@ -4526,7 +4569,7 @@ function readBackupImportRowsEffect(resolvedRepoPath, manifest) {
4526
4569
  });
4527
4570
  }
4528
4571
  function rowsForManifestPath(manifest, predicate) {
4529
- return manifest.files.map((file) => file.path).filter(predicate).sort();
4572
+ return manifest.files.map((file) => file.path).filter((relativePath) => predicate(logicalBackupShardPath(relativePath))).sort();
4530
4573
  }
4531
4574
  function importBackupEffect({ repoPath, db: providedDb, validate = true, mode = "merge" }) {
4532
4575
  return Effect.gen(function* () {
@@ -4901,7 +4944,7 @@ function validateBackupEffect(repoPath) {
4901
4944
  file,
4902
4945
  errors: fileErrors
4903
4946
  };
4904
- }), { concurrency: "unbounded" });
4947
+ }), { concurrency: 1 });
4905
4948
  const files = [];
4906
4949
  for (const result of results) {
4907
4950
  errors.push(...result.errors);
@@ -5130,7 +5130,7 @@ var getBaseManifest = getProdBaseManifest;
5130
5130
  var createEarlyHintsForRequest = createEarlyHintsCollector;
5131
5131
  async function loadEntries() {
5132
5132
  const [routerEntry, startEntry, pluginAdapters] = await Promise.all([
5133
- import("./assets/router-CAvZOdfA.js"),
5133
+ import("./assets/router-D8AUR_Gz.js"),
5134
5134
  import("./assets/start-FaDOTmto.js"),
5135
5135
  import("./assets/empty-plugin-adapters-DzrEkdBF.js")
5136
5136
  ]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "birdclaw",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "description": "Local Twitter memory in SQLite for archives, DMs, likes, bookmarks, and moderation",
5
5
  "homepage": "https://github.com/steipete/birdclaw#readme",
6
6
  "license": "MIT",