latticesql 4.2.2 → 4.2.3

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/cli.js CHANGED
@@ -1015,7 +1015,7 @@ var init_manifest = __esm({
1015
1015
  "src/lifecycle/manifest.ts"() {
1016
1016
  "use strict";
1017
1017
  init_writer();
1018
- TEMPLATE_VERSION = 2;
1018
+ TEMPLATE_VERSION = 3;
1019
1019
  }
1020
1020
  });
1021
1021
 
@@ -4208,6 +4208,64 @@ function cleanupEntityContexts(outputDir, entityContexts, currentSlugsByTable, m
4208
4208
  warnings: []
4209
4209
  };
4210
4210
  if (manifest === null) return result;
4211
+ if (options.removeOrphanedDirectories !== false) {
4212
+ for (const [table, entry] of Object.entries(manifest.entityContexts)) {
4213
+ if (entityContexts.has(table)) continue;
4214
+ const directoryRoot = entry.directoryRoot;
4215
+ const rootPath = join6(outputDir, directoryRoot);
4216
+ if (!existsSync6(rootPath)) continue;
4217
+ const globalProtected = new Set(options.protectedFiles ?? []);
4218
+ for (const [slug, files] of Object.entries(entry.entities)) {
4219
+ const entityDir = join6(rootPath, slug);
4220
+ if (!existsSync6(entityDir)) continue;
4221
+ for (const filename of entityFileNames(files)) {
4222
+ if (globalProtected.has(filename)) continue;
4223
+ const filePath = join6(entityDir, filename);
4224
+ if (!existsSync6(filePath)) continue;
4225
+ if (!options.dryRun) unlinkSync3(filePath);
4226
+ options.onOrphan?.(filePath, "file");
4227
+ result.filesRemoved.push(filePath);
4228
+ }
4229
+ let remaining;
4230
+ try {
4231
+ remaining = existsSync6(entityDir) ? readdirSync2(entityDir) : [];
4232
+ } catch {
4233
+ remaining = [];
4234
+ }
4235
+ if (remaining.length === 0) {
4236
+ if (!options.dryRun) {
4237
+ try {
4238
+ rmdirSync(entityDir);
4239
+ } catch {
4240
+ }
4241
+ }
4242
+ options.onOrphan?.(entityDir, "directory");
4243
+ result.directoriesRemoved.push(entityDir);
4244
+ } else {
4245
+ result.directoriesSkipped.push(entityDir);
4246
+ result.warnings.push(
4247
+ `${entityDir}: left in place (contains user files: ${remaining.join(", ")})`
4248
+ );
4249
+ }
4250
+ }
4251
+ let rootRemaining;
4252
+ try {
4253
+ rootRemaining = existsSync6(rootPath) ? readdirSync2(rootPath) : [];
4254
+ } catch {
4255
+ rootRemaining = [];
4256
+ }
4257
+ if (rootRemaining.length === 0) {
4258
+ if (!options.dryRun) {
4259
+ try {
4260
+ rmdirSync(rootPath);
4261
+ } catch {
4262
+ }
4263
+ }
4264
+ options.onOrphan?.(rootPath, "directory");
4265
+ result.directoriesRemoved.push(rootPath);
4266
+ }
4267
+ }
4268
+ }
4211
4269
  for (const [table, def] of entityContexts) {
4212
4270
  const entry = manifest.entityContexts[table];
4213
4271
  if (!entry) continue;
@@ -4704,7 +4762,8 @@ var init_engine = __esm({
4704
4762
  const currentSlugsByTable = /* @__PURE__ */ new Map();
4705
4763
  for (const [table, def] of entityContexts) {
4706
4764
  const rows = await this._schema.queryTable(this._adapter, table, this._schema.readRel);
4707
- const slugs = new Set(rows.map((row) => def.slug(row)));
4765
+ const entityPk = this._schema.getPrimaryKey(table)[0] ?? "id";
4766
+ const slugs = new Set(_RenderEngine._disambiguateSlugs(rows, def.slug, entityPk));
4708
4767
  currentSlugsByTable.set(table, slugs);
4709
4768
  }
4710
4769
  return cleanupEntityContexts(
@@ -4753,6 +4812,71 @@ var init_engine = __esm({
4753
4812
  static _normKey(v2) {
4754
4813
  return String(v2);
4755
4814
  }
4815
+ /**
4816
+ * Sanitize and validate ONE base slug.
4817
+ *
4818
+ * Replaces non-ASCII whitespace (e.g. the macOS narrow no-break space U+202F
4819
+ * that shows up in screenshot filenames) with a regular space, strips control
4820
+ * characters, then rejects any slug that still contains a character outside the
4821
+ * allowed set (the path-traversal guard). Throws on an invalid slug — never
4822
+ * silently rewrites it.
4823
+ */
4824
+ static _sanitizeSlug(rawSlug) {
4825
+ const slug = rawSlug.replace(/[\u00A0\u2000-\u200B\u202F\u205F\u3000]/g, " ").replace(/[\u0000-\u001F\u007F]/g, "");
4826
+ if (/[^a-zA-Z0-9.\-_ @(),#&'+:;!~[\]]/.test(slug)) {
4827
+ throw new Error(`Invalid slug "${slug}": contains characters outside the allowed set`);
4828
+ }
4829
+ return slug;
4830
+ }
4831
+ /**
4832
+ * Disambiguate per-row slugs so two rows that produce the SAME base slug do not
4833
+ * write to (and clobber) the same directory.
4834
+ *
4835
+ * Returns one final slug per row, in the SAME order as `rows`. A base slug used
4836
+ * by exactly one row is returned unchanged (no churn for the common case). When
4837
+ * a base slug is shared by >1 row, EVERY colliding row gets a short, stable
4838
+ * suffix derived from its primary key (`<base>-<pk8>`), so the result is
4839
+ * order-independent: the same row gets the same slug on every render regardless
4840
+ * of row order. The suffix lengthens only if two rows' 8-char PK prefixes still
4841
+ * collide (e.g. shared prefix), guaranteeing uniqueness without changing the
4842
+ * common-case output. Slugs are sanitized + path-traversal-validated via
4843
+ * {@link _sanitizeSlug}; `def.slug` itself is never modified.
4844
+ */
4845
+ static _disambiguateSlugs(rows, slugFn, pkCol) {
4846
+ const baseSlugs = rows.map((row) => _RenderEngine._sanitizeSlug(slugFn(row)));
4847
+ const byBase = /* @__PURE__ */ new Map();
4848
+ for (let i6 = 0; i6 < baseSlugs.length; i6++) {
4849
+ const base = baseSlugs[i6];
4850
+ const bucket = byBase.get(base);
4851
+ if (bucket) bucket.push(i6);
4852
+ else byBase.set(base, [i6]);
4853
+ }
4854
+ const final = baseSlugs.map(() => "");
4855
+ const pkOf = (i6) => {
4856
+ const v2 = rows[i6]?.[pkCol];
4857
+ let s2;
4858
+ if (v2 == null) s2 = "";
4859
+ else if (typeof v2 === "object") s2 = JSON.stringify(v2);
4860
+ else s2 = String(v2);
4861
+ return _RenderEngine._sanitizeSlug(s2).replace(/[ /\\]/g, "");
4862
+ };
4863
+ for (const [base, indices] of byBase) {
4864
+ if (indices.length === 1) {
4865
+ final[indices[0]] = base;
4866
+ continue;
4867
+ }
4868
+ const pks = indices.map(pkOf);
4869
+ const maxLen = Math.max(...pks.map((p3) => p3.length), 1);
4870
+ let len = 8;
4871
+ while (len < maxLen && new Set(pks.map((p3) => p3.slice(0, len))).size !== pks.length) {
4872
+ len += 4;
4873
+ }
4874
+ for (let k6 = 0; k6 < indices.length; k6++) {
4875
+ final[indices[k6]] = `${base}-${pks[k6].slice(0, len)}`;
4876
+ }
4877
+ }
4878
+ return final;
4879
+ }
4756
4880
  /**
4757
4881
  * Prefetch the batchable belongsTo sources for one entity-context table.
4758
4882
  * For each (target+filters+softDelete) group, issue exactly ONE
@@ -4833,6 +4957,7 @@ var init_engine = __esm({
4833
4957
  const baseRows = await this._schema.queryTable(this._adapter, table, this._schema.readRel);
4834
4958
  const allRows = this._foldRows ? await this._foldRows(table, baseRows) : baseRows;
4835
4959
  const directoryRoot = def.directoryRoot ?? table;
4960
+ const finalSlugs = _RenderEngine._disambiguateSlugs(allRows, def.slug, entityPk);
4836
4961
  const belongsToBatches = await this._prefetchBelongsToBatches(
4837
4962
  def,
4838
4963
  allRows,
@@ -4874,11 +4999,7 @@ var init_engine = __esm({
4874
4999
  if (i6 > 0 && i6 % YIELD_EVERY_ENTITIES === 0) {
4875
5000
  await new Promise((r6) => setImmediate(r6));
4876
5001
  }
4877
- const rawSlug = def.slug(entityRow);
4878
- const slug = rawSlug.replace(/[\u00A0\u2000-\u200B\u202F\u205F\u3000]/g, " ").replace(/[\u0000-\u001F\u007F]/g, "");
4879
- if (/[^a-zA-Z0-9.\-_ @(),#&'+:;!~[\]]/.test(slug)) {
4880
- throw new Error(`Invalid slug "${slug}": contains characters outside the allowed set`);
4881
- }
5002
+ const slug = finalSlugs[i6];
4882
5003
  const entityDir = def.directory ? join7(outputDir, def.directory(entityRow)) : join7(outputDir, directoryRoot, slug);
4883
5004
  const resolvedDir = resolve3(entityDir);
4884
5005
  const resolvedBase = resolve3(outputDir);
@@ -76617,7 +76738,7 @@ function printHelp() {
76617
76738
  );
76618
76739
  }
76619
76740
  function getVersion() {
76620
- if (true) return "4.2.2";
76741
+ if (true) return "4.2.3";
76621
76742
  try {
76622
76743
  const pkgPath = new URL("../package.json", import.meta.url).pathname;
76623
76744
  const pkg = JSON.parse(readFileSync25(pkgPath, "utf-8"));
package/dist/index.cjs CHANGED
@@ -249,7 +249,7 @@ var init_manifest = __esm({
249
249
  import_node_path2 = require("path");
250
250
  import_node_fs2 = require("fs");
251
251
  init_writer();
252
- TEMPLATE_VERSION = 2;
252
+ TEMPLATE_VERSION = 3;
253
253
  }
254
254
  });
255
255
 
@@ -3446,6 +3446,64 @@ function cleanupEntityContexts(outputDir, entityContexts, currentSlugsByTable, m
3446
3446
  warnings: []
3447
3447
  };
3448
3448
  if (manifest === null) return result;
3449
+ if (options.removeOrphanedDirectories !== false) {
3450
+ for (const [table, entry] of Object.entries(manifest.entityContexts)) {
3451
+ if (entityContexts.has(table)) continue;
3452
+ const directoryRoot = entry.directoryRoot;
3453
+ const rootPath = (0, import_node_path5.join)(outputDir, directoryRoot);
3454
+ if (!(0, import_node_fs3.existsSync)(rootPath)) continue;
3455
+ const globalProtected = new Set(options.protectedFiles ?? []);
3456
+ for (const [slug, files] of Object.entries(entry.entities)) {
3457
+ const entityDir = (0, import_node_path5.join)(rootPath, slug);
3458
+ if (!(0, import_node_fs3.existsSync)(entityDir)) continue;
3459
+ for (const filename of entityFileNames(files)) {
3460
+ if (globalProtected.has(filename)) continue;
3461
+ const filePath = (0, import_node_path5.join)(entityDir, filename);
3462
+ if (!(0, import_node_fs3.existsSync)(filePath)) continue;
3463
+ if (!options.dryRun) (0, import_node_fs3.unlinkSync)(filePath);
3464
+ options.onOrphan?.(filePath, "file");
3465
+ result.filesRemoved.push(filePath);
3466
+ }
3467
+ let remaining;
3468
+ try {
3469
+ remaining = (0, import_node_fs3.existsSync)(entityDir) ? (0, import_node_fs3.readdirSync)(entityDir) : [];
3470
+ } catch {
3471
+ remaining = [];
3472
+ }
3473
+ if (remaining.length === 0) {
3474
+ if (!options.dryRun) {
3475
+ try {
3476
+ (0, import_node_fs3.rmdirSync)(entityDir);
3477
+ } catch {
3478
+ }
3479
+ }
3480
+ options.onOrphan?.(entityDir, "directory");
3481
+ result.directoriesRemoved.push(entityDir);
3482
+ } else {
3483
+ result.directoriesSkipped.push(entityDir);
3484
+ result.warnings.push(
3485
+ `${entityDir}: left in place (contains user files: ${remaining.join(", ")})`
3486
+ );
3487
+ }
3488
+ }
3489
+ let rootRemaining;
3490
+ try {
3491
+ rootRemaining = (0, import_node_fs3.existsSync)(rootPath) ? (0, import_node_fs3.readdirSync)(rootPath) : [];
3492
+ } catch {
3493
+ rootRemaining = [];
3494
+ }
3495
+ if (rootRemaining.length === 0) {
3496
+ if (!options.dryRun) {
3497
+ try {
3498
+ (0, import_node_fs3.rmdirSync)(rootPath);
3499
+ } catch {
3500
+ }
3501
+ }
3502
+ options.onOrphan?.(rootPath, "directory");
3503
+ result.directoriesRemoved.push(rootPath);
3504
+ }
3505
+ }
3506
+ }
3449
3507
  for (const [table, def] of entityContexts) {
3450
3508
  const entry = manifest.entityContexts[table];
3451
3509
  if (!entry) continue;
@@ -3945,7 +4003,8 @@ var init_engine = __esm({
3945
4003
  const currentSlugsByTable = /* @__PURE__ */ new Map();
3946
4004
  for (const [table, def] of entityContexts) {
3947
4005
  const rows = await this._schema.queryTable(this._adapter, table, this._schema.readRel);
3948
- const slugs = new Set(rows.map((row) => def.slug(row)));
4006
+ const entityPk = this._schema.getPrimaryKey(table)[0] ?? "id";
4007
+ const slugs = new Set(_RenderEngine._disambiguateSlugs(rows, def.slug, entityPk));
3949
4008
  currentSlugsByTable.set(table, slugs);
3950
4009
  }
3951
4010
  return cleanupEntityContexts(
@@ -3994,6 +4053,71 @@ var init_engine = __esm({
3994
4053
  static _normKey(v2) {
3995
4054
  return String(v2);
3996
4055
  }
4056
+ /**
4057
+ * Sanitize and validate ONE base slug.
4058
+ *
4059
+ * Replaces non-ASCII whitespace (e.g. the macOS narrow no-break space U+202F
4060
+ * that shows up in screenshot filenames) with a regular space, strips control
4061
+ * characters, then rejects any slug that still contains a character outside the
4062
+ * allowed set (the path-traversal guard). Throws on an invalid slug — never
4063
+ * silently rewrites it.
4064
+ */
4065
+ static _sanitizeSlug(rawSlug) {
4066
+ const slug = rawSlug.replace(/[\u00A0\u2000-\u200B\u202F\u205F\u3000]/g, " ").replace(/[\u0000-\u001F\u007F]/g, "");
4067
+ if (/[^a-zA-Z0-9.\-_ @(),#&'+:;!~[\]]/.test(slug)) {
4068
+ throw new Error(`Invalid slug "${slug}": contains characters outside the allowed set`);
4069
+ }
4070
+ return slug;
4071
+ }
4072
+ /**
4073
+ * Disambiguate per-row slugs so two rows that produce the SAME base slug do not
4074
+ * write to (and clobber) the same directory.
4075
+ *
4076
+ * Returns one final slug per row, in the SAME order as `rows`. A base slug used
4077
+ * by exactly one row is returned unchanged (no churn for the common case). When
4078
+ * a base slug is shared by >1 row, EVERY colliding row gets a short, stable
4079
+ * suffix derived from its primary key (`<base>-<pk8>`), so the result is
4080
+ * order-independent: the same row gets the same slug on every render regardless
4081
+ * of row order. The suffix lengthens only if two rows' 8-char PK prefixes still
4082
+ * collide (e.g. shared prefix), guaranteeing uniqueness without changing the
4083
+ * common-case output. Slugs are sanitized + path-traversal-validated via
4084
+ * {@link _sanitizeSlug}; `def.slug` itself is never modified.
4085
+ */
4086
+ static _disambiguateSlugs(rows, slugFn, pkCol) {
4087
+ const baseSlugs = rows.map((row) => _RenderEngine._sanitizeSlug(slugFn(row)));
4088
+ const byBase = /* @__PURE__ */ new Map();
4089
+ for (let i6 = 0; i6 < baseSlugs.length; i6++) {
4090
+ const base = baseSlugs[i6];
4091
+ const bucket = byBase.get(base);
4092
+ if (bucket) bucket.push(i6);
4093
+ else byBase.set(base, [i6]);
4094
+ }
4095
+ const final = baseSlugs.map(() => "");
4096
+ const pkOf = (i6) => {
4097
+ const v2 = rows[i6]?.[pkCol];
4098
+ let s2;
4099
+ if (v2 == null) s2 = "";
4100
+ else if (typeof v2 === "object") s2 = JSON.stringify(v2);
4101
+ else s2 = String(v2);
4102
+ return _RenderEngine._sanitizeSlug(s2).replace(/[ /\\]/g, "");
4103
+ };
4104
+ for (const [base, indices] of byBase) {
4105
+ if (indices.length === 1) {
4106
+ final[indices[0]] = base;
4107
+ continue;
4108
+ }
4109
+ const pks = indices.map(pkOf);
4110
+ const maxLen = Math.max(...pks.map((p3) => p3.length), 1);
4111
+ let len = 8;
4112
+ while (len < maxLen && new Set(pks.map((p3) => p3.slice(0, len))).size !== pks.length) {
4113
+ len += 4;
4114
+ }
4115
+ for (let k6 = 0; k6 < indices.length; k6++) {
4116
+ final[indices[k6]] = `${base}-${pks[k6].slice(0, len)}`;
4117
+ }
4118
+ }
4119
+ return final;
4120
+ }
3997
4121
  /**
3998
4122
  * Prefetch the batchable belongsTo sources for one entity-context table.
3999
4123
  * For each (target+filters+softDelete) group, issue exactly ONE
@@ -4074,6 +4198,7 @@ var init_engine = __esm({
4074
4198
  const baseRows = await this._schema.queryTable(this._adapter, table, this._schema.readRel);
4075
4199
  const allRows = this._foldRows ? await this._foldRows(table, baseRows) : baseRows;
4076
4200
  const directoryRoot = def.directoryRoot ?? table;
4201
+ const finalSlugs = _RenderEngine._disambiguateSlugs(allRows, def.slug, entityPk);
4077
4202
  const belongsToBatches = await this._prefetchBelongsToBatches(
4078
4203
  def,
4079
4204
  allRows,
@@ -4115,11 +4240,7 @@ var init_engine = __esm({
4115
4240
  if (i6 > 0 && i6 % YIELD_EVERY_ENTITIES === 0) {
4116
4241
  await new Promise((r6) => setImmediate(r6));
4117
4242
  }
4118
- const rawSlug = def.slug(entityRow);
4119
- const slug = rawSlug.replace(/[\u00A0\u2000-\u200B\u202F\u205F\u3000]/g, " ").replace(/[\u0000-\u001F\u007F]/g, "");
4120
- if (/[^a-zA-Z0-9.\-_ @(),#&'+:;!~[\]]/.test(slug)) {
4121
- throw new Error(`Invalid slug "${slug}": contains characters outside the allowed set`);
4122
- }
4243
+ const slug = finalSlugs[i6];
4123
4244
  const entityDir = def.directory ? (0, import_node_path6.join)(outputDir, def.directory(entityRow)) : (0, import_node_path6.join)(outputDir, directoryRoot, slug);
4124
4245
  const resolvedDir = (0, import_node_path6.resolve)(entityDir);
4125
4246
  const resolvedBase = (0, import_node_path6.resolve)(outputDir);
package/dist/index.js CHANGED
@@ -240,7 +240,7 @@ var init_manifest = __esm({
240
240
  "src/lifecycle/manifest.ts"() {
241
241
  "use strict";
242
242
  init_writer();
243
- TEMPLATE_VERSION = 2;
243
+ TEMPLATE_VERSION = 3;
244
244
  }
245
245
  });
246
246
 
@@ -3437,6 +3437,64 @@ function cleanupEntityContexts(outputDir, entityContexts, currentSlugsByTable, m
3437
3437
  warnings: []
3438
3438
  };
3439
3439
  if (manifest === null) return result;
3440
+ if (options.removeOrphanedDirectories !== false) {
3441
+ for (const [table, entry] of Object.entries(manifest.entityContexts)) {
3442
+ if (entityContexts.has(table)) continue;
3443
+ const directoryRoot = entry.directoryRoot;
3444
+ const rootPath = join3(outputDir, directoryRoot);
3445
+ if (!existsSync3(rootPath)) continue;
3446
+ const globalProtected = new Set(options.protectedFiles ?? []);
3447
+ for (const [slug, files] of Object.entries(entry.entities)) {
3448
+ const entityDir = join3(rootPath, slug);
3449
+ if (!existsSync3(entityDir)) continue;
3450
+ for (const filename of entityFileNames(files)) {
3451
+ if (globalProtected.has(filename)) continue;
3452
+ const filePath = join3(entityDir, filename);
3453
+ if (!existsSync3(filePath)) continue;
3454
+ if (!options.dryRun) unlinkSync2(filePath);
3455
+ options.onOrphan?.(filePath, "file");
3456
+ result.filesRemoved.push(filePath);
3457
+ }
3458
+ let remaining;
3459
+ try {
3460
+ remaining = existsSync3(entityDir) ? readdirSync(entityDir) : [];
3461
+ } catch {
3462
+ remaining = [];
3463
+ }
3464
+ if (remaining.length === 0) {
3465
+ if (!options.dryRun) {
3466
+ try {
3467
+ rmdirSync(entityDir);
3468
+ } catch {
3469
+ }
3470
+ }
3471
+ options.onOrphan?.(entityDir, "directory");
3472
+ result.directoriesRemoved.push(entityDir);
3473
+ } else {
3474
+ result.directoriesSkipped.push(entityDir);
3475
+ result.warnings.push(
3476
+ `${entityDir}: left in place (contains user files: ${remaining.join(", ")})`
3477
+ );
3478
+ }
3479
+ }
3480
+ let rootRemaining;
3481
+ try {
3482
+ rootRemaining = existsSync3(rootPath) ? readdirSync(rootPath) : [];
3483
+ } catch {
3484
+ rootRemaining = [];
3485
+ }
3486
+ if (rootRemaining.length === 0) {
3487
+ if (!options.dryRun) {
3488
+ try {
3489
+ rmdirSync(rootPath);
3490
+ } catch {
3491
+ }
3492
+ }
3493
+ options.onOrphan?.(rootPath, "directory");
3494
+ result.directoriesRemoved.push(rootPath);
3495
+ }
3496
+ }
3497
+ }
3440
3498
  for (const [table, def] of entityContexts) {
3441
3499
  const entry = manifest.entityContexts[table];
3442
3500
  if (!entry) continue;
@@ -3933,7 +3991,8 @@ var init_engine = __esm({
3933
3991
  const currentSlugsByTable = /* @__PURE__ */ new Map();
3934
3992
  for (const [table, def] of entityContexts) {
3935
3993
  const rows = await this._schema.queryTable(this._adapter, table, this._schema.readRel);
3936
- const slugs = new Set(rows.map((row) => def.slug(row)));
3994
+ const entityPk = this._schema.getPrimaryKey(table)[0] ?? "id";
3995
+ const slugs = new Set(_RenderEngine._disambiguateSlugs(rows, def.slug, entityPk));
3937
3996
  currentSlugsByTable.set(table, slugs);
3938
3997
  }
3939
3998
  return cleanupEntityContexts(
@@ -3982,6 +4041,71 @@ var init_engine = __esm({
3982
4041
  static _normKey(v2) {
3983
4042
  return String(v2);
3984
4043
  }
4044
+ /**
4045
+ * Sanitize and validate ONE base slug.
4046
+ *
4047
+ * Replaces non-ASCII whitespace (e.g. the macOS narrow no-break space U+202F
4048
+ * that shows up in screenshot filenames) with a regular space, strips control
4049
+ * characters, then rejects any slug that still contains a character outside the
4050
+ * allowed set (the path-traversal guard). Throws on an invalid slug — never
4051
+ * silently rewrites it.
4052
+ */
4053
+ static _sanitizeSlug(rawSlug) {
4054
+ const slug = rawSlug.replace(/[\u00A0\u2000-\u200B\u202F\u205F\u3000]/g, " ").replace(/[\u0000-\u001F\u007F]/g, "");
4055
+ if (/[^a-zA-Z0-9.\-_ @(),#&'+:;!~[\]]/.test(slug)) {
4056
+ throw new Error(`Invalid slug "${slug}": contains characters outside the allowed set`);
4057
+ }
4058
+ return slug;
4059
+ }
4060
+ /**
4061
+ * Disambiguate per-row slugs so two rows that produce the SAME base slug do not
4062
+ * write to (and clobber) the same directory.
4063
+ *
4064
+ * Returns one final slug per row, in the SAME order as `rows`. A base slug used
4065
+ * by exactly one row is returned unchanged (no churn for the common case). When
4066
+ * a base slug is shared by >1 row, EVERY colliding row gets a short, stable
4067
+ * suffix derived from its primary key (`<base>-<pk8>`), so the result is
4068
+ * order-independent: the same row gets the same slug on every render regardless
4069
+ * of row order. The suffix lengthens only if two rows' 8-char PK prefixes still
4070
+ * collide (e.g. shared prefix), guaranteeing uniqueness without changing the
4071
+ * common-case output. Slugs are sanitized + path-traversal-validated via
4072
+ * {@link _sanitizeSlug}; `def.slug` itself is never modified.
4073
+ */
4074
+ static _disambiguateSlugs(rows, slugFn, pkCol) {
4075
+ const baseSlugs = rows.map((row) => _RenderEngine._sanitizeSlug(slugFn(row)));
4076
+ const byBase = /* @__PURE__ */ new Map();
4077
+ for (let i6 = 0; i6 < baseSlugs.length; i6++) {
4078
+ const base = baseSlugs[i6];
4079
+ const bucket = byBase.get(base);
4080
+ if (bucket) bucket.push(i6);
4081
+ else byBase.set(base, [i6]);
4082
+ }
4083
+ const final = baseSlugs.map(() => "");
4084
+ const pkOf = (i6) => {
4085
+ const v2 = rows[i6]?.[pkCol];
4086
+ let s2;
4087
+ if (v2 == null) s2 = "";
4088
+ else if (typeof v2 === "object") s2 = JSON.stringify(v2);
4089
+ else s2 = String(v2);
4090
+ return _RenderEngine._sanitizeSlug(s2).replace(/[ /\\]/g, "");
4091
+ };
4092
+ for (const [base, indices] of byBase) {
4093
+ if (indices.length === 1) {
4094
+ final[indices[0]] = base;
4095
+ continue;
4096
+ }
4097
+ const pks = indices.map(pkOf);
4098
+ const maxLen = Math.max(...pks.map((p3) => p3.length), 1);
4099
+ let len = 8;
4100
+ while (len < maxLen && new Set(pks.map((p3) => p3.slice(0, len))).size !== pks.length) {
4101
+ len += 4;
4102
+ }
4103
+ for (let k6 = 0; k6 < indices.length; k6++) {
4104
+ final[indices[k6]] = `${base}-${pks[k6].slice(0, len)}`;
4105
+ }
4106
+ }
4107
+ return final;
4108
+ }
3985
4109
  /**
3986
4110
  * Prefetch the batchable belongsTo sources for one entity-context table.
3987
4111
  * For each (target+filters+softDelete) group, issue exactly ONE
@@ -4062,6 +4186,7 @@ var init_engine = __esm({
4062
4186
  const baseRows = await this._schema.queryTable(this._adapter, table, this._schema.readRel);
4063
4187
  const allRows = this._foldRows ? await this._foldRows(table, baseRows) : baseRows;
4064
4188
  const directoryRoot = def.directoryRoot ?? table;
4189
+ const finalSlugs = _RenderEngine._disambiguateSlugs(allRows, def.slug, entityPk);
4065
4190
  const belongsToBatches = await this._prefetchBelongsToBatches(
4066
4191
  def,
4067
4192
  allRows,
@@ -4103,11 +4228,7 @@ var init_engine = __esm({
4103
4228
  if (i6 > 0 && i6 % YIELD_EVERY_ENTITIES === 0) {
4104
4229
  await new Promise((r6) => setImmediate(r6));
4105
4230
  }
4106
- const rawSlug = def.slug(entityRow);
4107
- const slug = rawSlug.replace(/[\u00A0\u2000-\u200B\u202F\u205F\u3000]/g, " ").replace(/[\u0000-\u001F\u007F]/g, "");
4108
- if (/[^a-zA-Z0-9.\-_ @(),#&'+:;!~[\]]/.test(slug)) {
4109
- throw new Error(`Invalid slug "${slug}": contains characters outside the allowed set`);
4110
- }
4231
+ const slug = finalSlugs[i6];
4111
4232
  const entityDir = def.directory ? join4(outputDir, def.directory(entityRow)) : join4(outputDir, directoryRoot, slug);
4112
4233
  const resolvedDir = resolve(entityDir);
4113
4234
  const resolvedBase = resolve(outputDir);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "latticesql",
3
- "version": "4.2.2",
3
+ "version": "4.2.3",
4
4
  "description": "Persistent structured memory for AI agent systems — pluggable SQLite or Postgres backend, LLM context bridge",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",