thaizip 0.7.2 → 0.7.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/index.cjs CHANGED
@@ -4,8 +4,13 @@
4
4
  var TONE_MARKS = /[่-๋็์]/g;
5
5
  var ADDRESS_PREFIXES = /^(จังหวัด|อำเภอ|ตำบล|แขวง|เขต|จ\.|อ\.|ต\.|ข\.)/;
6
6
  function normalizeThaiAddressText(input) {
7
- if (!input) return "";
8
- let text = input.trim();
7
+ if (typeof input !== "string") {
8
+ throw new TypeError(
9
+ `[thaizip] normalizeThaiAddressText expected string, got ${input === null ? "null" : typeof input}`
10
+ );
11
+ }
12
+ if (input.length === 0) return "";
13
+ let text = input.normalize("NFC").trim();
9
14
  text = text.replace(ADDRESS_PREFIXES, "").trim();
10
15
  text = text.replace(TONE_MARKS, "");
11
16
  text = text.toLowerCase();
@@ -33,24 +38,57 @@ function extractTrigramsNormalized(text) {
33
38
  function typeErr(table, id, field, expected, value) {
34
39
  return new TypeError(`[thaizip] ${table} ${id}: expected ${expected} for ${field}, got ${typeof value}`);
35
40
  }
41
+ function typeName(value) {
42
+ return value === null ? "null" : typeof value;
43
+ }
36
44
  function validateRawData(data) {
45
+ if (data === null || typeof data !== "object") {
46
+ throw new TypeError(`[thaizip] validateRawData expected a RawData object, got ${typeName(data)}`);
47
+ }
37
48
  const { provinces, amphures, tambons } = data;
49
+ if (!Array.isArray(provinces)) {
50
+ throw new TypeError(`[thaizip] RawData.provinces must be an array, got ${typeName(provinces)}`);
51
+ }
52
+ if (!Array.isArray(amphures)) {
53
+ throw new TypeError(`[thaizip] RawData.amphures must be an array, got ${typeName(amphures)}`);
54
+ }
55
+ if (!Array.isArray(tambons)) {
56
+ throw new TypeError(`[thaizip] RawData.tambons must be an array, got ${typeName(tambons)}`);
57
+ }
58
+ const seenProvinceIds = /* @__PURE__ */ new Set();
38
59
  for (let i = 0; i < provinces.length; i++) {
39
60
  const p = provinces[i];
61
+ if (p === null || typeof p !== "object") {
62
+ throw new TypeError(`[thaizip] province[${i}]: expected object, got ${typeName(p)}`);
63
+ }
40
64
  if (typeof p.id !== "number") throw typeErr("province", p.id, "id", "number", p.id);
65
+ if (seenProvinceIds.has(p.id)) throw new TypeError(`[thaizip] duplicate province id: ${p.id}`);
66
+ seenProvinceIds.add(p.id);
41
67
  if (typeof p.name_th !== "string") throw typeErr("province", p.id, "name_th", "string", p.name_th);
42
68
  if (typeof p.name_en !== "string") throw typeErr("province", p.id, "name_en", "string", p.name_en);
43
69
  }
70
+ const seenAmphureIds = /* @__PURE__ */ new Set();
44
71
  for (let i = 0; i < amphures.length; i++) {
45
72
  const a = amphures[i];
73
+ if (a === null || typeof a !== "object") {
74
+ throw new TypeError(`[thaizip] amphure[${i}]: expected object, got ${typeName(a)}`);
75
+ }
46
76
  if (typeof a.id !== "number") throw typeErr("amphure", a.id, "id", "number", a.id);
77
+ if (seenAmphureIds.has(a.id)) throw new TypeError(`[thaizip] duplicate amphure id: ${a.id}`);
78
+ seenAmphureIds.add(a.id);
47
79
  if (typeof a.name_th !== "string") throw typeErr("amphure", a.id, "name_th", "string", a.name_th);
48
80
  if (typeof a.name_en !== "string") throw typeErr("amphure", a.id, "name_en", "string", a.name_en);
49
81
  if (typeof a.province_id !== "number") throw typeErr("amphure", a.id, "province_id", "number", a.province_id);
50
82
  }
83
+ const seenTambonIds = /* @__PURE__ */ new Set();
51
84
  for (let i = 0; i < tambons.length; i++) {
52
85
  const t = tambons[i];
86
+ if (t === null || typeof t !== "object") {
87
+ throw new TypeError(`[thaizip] tambon[${i}]: expected object, got ${typeName(t)}`);
88
+ }
53
89
  if (typeof t.id !== "number") throw typeErr("tambon", t.id, "id", "number", t.id);
90
+ if (seenTambonIds.has(t.id)) throw new TypeError(`[thaizip] duplicate tambon id: ${t.id}`);
91
+ seenTambonIds.add(t.id);
54
92
  if (typeof t.name_th !== "string") throw typeErr("tambon", t.id, "name_th", "string", t.name_th);
55
93
  if (typeof t.name_en !== "string") throw typeErr("tambon", t.id, "name_en", "string", t.name_en);
56
94
  const zipType = typeof t.zip_code;
@@ -147,7 +185,9 @@ function buildThaiAddressIndex(data, options) {
147
185
  addTrigrams(map, provTrigrams.get(province.id), idx);
148
186
  addTrigrams(map, ampTrigrams.get(amphure.id), idx);
149
187
  }
150
- return { map, records, zipIndex, normTambon, normTambonEn, byProvince, byAmphure };
188
+ const sortedZipKeys = [...zipIndex.keys()].sort();
189
+ const sortedZipPostings = sortedZipKeys.map((z) => zipIndex.get(z));
190
+ return { map, records, zipIndex, sortedZipKeys, sortedZipPostings, normTambon, normTambonEn, byProvince, byAmphure };
151
191
  }
152
192
 
153
193
  // src/core/romanize.ts
@@ -276,19 +316,40 @@ function sortZipMatches(matches, exactZip) {
276
316
  });
277
317
  return matches;
278
318
  }
319
+ function normalizeLimit(value) {
320
+ if (Number.isNaN(value) || value === -Infinity) return 0;
321
+ if (value === Infinity) return value;
322
+ return Math.max(0, Math.floor(value));
323
+ }
279
324
  function lookupByZipCode(index, zip, options) {
280
- if (!index || !zip) return [];
325
+ if (!index || !index.zipIndex || !index.records || typeof zip !== "string") return [];
281
326
  const normalized = zip.trim();
282
327
  if (!ZIP_CODE_RE.test(normalized) || normalized.length < 2) return [];
283
- const zipLimit = options?.zipLimit ?? Infinity;
328
+ const zipLimit = normalizeLimit(options?.zipLimit ?? Infinity);
284
329
  const matches = [];
285
- for (const [z, indices] of index.zipIndex) {
286
- if (z.startsWith(normalized)) {
287
- for (const idx of indices) matches.push(index.records[idx]);
330
+ const keys = index.sortedZipKeys;
331
+ const postings = index.sortedZipPostings;
332
+ if (keys !== void 0 && postings !== void 0) {
333
+ let lo = 0;
334
+ let hi = keys.length;
335
+ while (lo < hi) {
336
+ const mid = lo + hi >> 1;
337
+ if (keys[mid] < normalized) lo = mid + 1;
338
+ else hi = mid;
339
+ }
340
+ for (let i = lo; i < keys.length && keys[i].startsWith(normalized); i++) {
341
+ const indices = postings[i];
342
+ for (let j = 0; j < indices.length; j++) matches.push(index.records[indices[j]]);
343
+ }
344
+ } else {
345
+ for (const [z, indices] of index.zipIndex) {
346
+ if (z.startsWith(normalized)) {
347
+ for (const idx of indices) matches.push(index.records[idx]);
348
+ }
288
349
  }
350
+ sortZipMatches(matches, normalized);
289
351
  }
290
- sortZipMatches(matches, normalized);
291
- return matches.slice(0, zipLimit);
352
+ return matches.slice(0, zipLimit).map((record) => ({ ...record }));
292
353
  }
293
354
  function rankAgainstName(name, query) {
294
355
  if (name === void 0 || name.length === 0) return 0;
@@ -303,10 +364,20 @@ function computeMatchRank(index, idx, query) {
303
364
  const en = rankAgainstName(index.normTambonEn?.[idx], query);
304
365
  return en > th ? en : th;
305
366
  }
367
+ var scratchByIndex = /* @__PURE__ */ new WeakMap();
368
+ function getScratch(index) {
369
+ const n = index.records.length;
370
+ let s = scratchByIndex.get(index);
371
+ if (s === void 0 || s.counts.length < n) {
372
+ s = { counts: new Uint32Array(n), touched: new Int32Array(n) };
373
+ scratchByIndex.set(index, s);
374
+ }
375
+ return s;
376
+ }
306
377
  function searchThaiAddress(index, query, options) {
307
- const limit = options?.limit ?? 10;
378
+ const limit = normalizeLimit(options?.limit ?? 10);
308
379
  const threshold = options?.threshold ?? 0.4;
309
- if (!index || !query) return [];
380
+ if (!index || !index.map || !index.records || typeof query !== "string") return [];
310
381
  if (query.length > 1e3) return [];
311
382
  const normalized = normalizeThaiAddressText(query);
312
383
  if (normalized.length === 0) return [];
@@ -320,17 +391,22 @@ function searchThaiAddress(index, query, options) {
320
391
  if (normalized.length < 3) return [];
321
392
  const searchText = options?.romanizationAliases !== false && HAS_LATIN_RE.test(normalized) ? applyRomanizationAliases(normalized) : normalized;
322
393
  const queryTrigrams = extractTrigramsNormalized(searchText);
323
- const hits = /* @__PURE__ */ new Map();
394
+ const { counts, touched } = getScratch(index);
395
+ let touchedLen = 0;
324
396
  for (const trigram of queryTrigrams) {
325
397
  const candidates = index.map.get(trigram);
326
398
  if (!candidates) continue;
327
399
  for (const idx of candidates) {
328
- hits.set(idx, (hits.get(idx) ?? 0) + 1);
400
+ if (counts[idx] === 0) touched[touchedLen++] = idx;
401
+ counts[idx]++;
329
402
  }
330
403
  }
331
404
  const scored = [];
332
- for (const [idx, count] of hits) {
333
- const score = count / queryTrigrams.size;
405
+ const totalTrigrams = queryTrigrams.size;
406
+ for (let i = 0; i < touchedLen; i++) {
407
+ const idx = touched[i];
408
+ const score = counts[idx] / totalTrigrams;
409
+ counts[idx] = 0;
334
410
  if (score >= threshold) {
335
411
  scored.push({ idx, score, matchRank: computeMatchRank(index, idx, searchText) });
336
412
  }
@@ -348,7 +424,7 @@ function searchThaiAddress(index, query, options) {
348
424
  const rb = index.records[b.idx];
349
425
  return TH_COLLATOR.compare(ra.provinceNameTh, rb.provinceNameTh) || TH_COLLATOR.compare(ra.amphureNameTh, rb.amphureNameTh) || TH_COLLATOR.compare(ra.tambonNameTh, rb.tambonNameTh);
350
426
  });
351
- return window.slice(0, limit).map(({ idx }) => index.records[idx]);
427
+ return window.slice(0, limit).map(({ idx }) => ({ ...index.records[idx] }));
352
428
  }
353
429
 
354
430
  // src/core/enumerate.ts
@@ -357,6 +433,7 @@ function byNameTh(a, b) {
357
433
  return thCollator.compare(a.nameTh, b.nameTh);
358
434
  }
359
435
  function listProvinces(index) {
436
+ if (!index || !index.records) return [];
360
437
  const seen = /* @__PURE__ */ new Map();
361
438
  if (index.byProvince) {
362
439
  for (const [provinceId, recordIndices] of index.byProvince) {
@@ -374,6 +451,7 @@ function listProvinces(index) {
374
451
  return Array.from(seen.values()).sort(byNameTh);
375
452
  }
376
453
  function listAmphures(index, provinceId) {
454
+ if (!index || !index.records) return [];
377
455
  const seen = /* @__PURE__ */ new Map();
378
456
  const addFrom = (r) => {
379
457
  if (!seen.has(r.amphureId)) {
@@ -397,6 +475,7 @@ function listAmphures(index, provinceId) {
397
475
  return Array.from(seen.values()).sort(byNameTh);
398
476
  }
399
477
  function listTambons(index, amphureId) {
478
+ if (!index || !index.records) return [];
400
479
  const toSummary = (r) => ({
401
480
  id: r.tambonId,
402
481
  nameTh: r.tambonNameTh,
package/dist/index.d.cts CHANGED
@@ -14,6 +14,15 @@ type TrigramIndex = {
14
14
  map: Map<string, Set<number>>;
15
15
  records: ThaiAddressRecord[];
16
16
  zipIndex: Map<string, number[]>;
17
+ /**
18
+ * Zip codes of `zipIndex` in ascending lexical order, with postings
19
+ * parallel in `sortedZipPostings`. Used by `lookupByZipCode` for
20
+ * O(log n + matches) prefix lookup instead of scanning every zip code.
21
+ * Optional: older-shaped or hand-built indexes without these fields fall
22
+ * back to the O(n_zips) scan.
23
+ */
24
+ sortedZipKeys?: string[];
25
+ sortedZipPostings?: number[][];
17
26
  /**
18
27
  * Normalized Thai tambon names, parallel to `records` (same index).
19
28
  * Used by the search ranker for exact/prefix match detection without
@@ -156,6 +165,12 @@ type RawData = {
156
165
  * offending record's `id`, the field, and the actual `typeof` on the first bad
157
166
  * field found. Does not filter soft-deleted rows first — validates every row as
158
167
  * provided.
168
+ *
169
+ * IDs must be unique within each table (duplicates throw, they are not silently
170
+ * discarded — two suggestions sharing an id would resolve to the wrong record).
171
+ * Referential integrity (amphure→province, tambon→amphure) is intentionally NOT
172
+ * enforced: dangling references — including soft-deleted parents — are handled at
173
+ * build time, where the tambon is skipped and reported via `options.onSkip`.
159
174
  */
160
175
  declare function validateRawData(data: RawData): void;
161
176
  declare function buildThaiAddressIndex(data: RawData, options?: BuildIndexOptions): TrigramIndex;
@@ -164,9 +179,22 @@ declare function buildThaiAddressIndex(data: RawData, options?: BuildIndexOption
164
179
  * Look up records by zip code (exact or prefix match). Exact matches are
165
180
  * returned before prefix matches; ties are ordered by ascending zip code.
166
181
  *
182
+ * Uses the build-time `sortedZipKeys`/`sortedZipPostings` arrays with a
183
+ * binary search for the first key ≥ the query, then walks the contiguous
184
+ * prefix range — O(log n_zips + matches). Ascending key order places an
185
+ * exact match before any longer prefix match automatically, so no explicit
186
+ * exact-first sort is needed. Indexes without the sorted arrays (older
187
+ * shape, hand-built) fall back to the original O(n_zips) scan + sort.
188
+ *
189
+ * Returned records are shallow copies: mutating them cannot corrupt the
190
+ * shared index (see `searchThaiAddress`).
191
+ *
167
192
  * Unlike `searchThaiAddress`, results are NOT capped by `options.limit` —
168
193
  * only by `options.zipLimit` (defaults to `Infinity`), since a zip code can
169
194
  * legitimately map to dozens of tambons (e.g. `45000` → 33 records).
195
+ *
196
+ * Malformed runtime input (non-string `zip`, missing index shape) returns
197
+ * `[]` rather than crashing deep inside the library.
170
198
  */
171
199
  declare function lookupByZipCode(index: TrigramIndex, zip: string, options?: SearchOptions): ThaiAddressRecord[];
172
200
  declare function searchThaiAddress(index: TrigramIndex, query: string, options?: SearchOptions): ThaiAddressRecord[];
@@ -174,13 +202,15 @@ declare function searchThaiAddress(index: TrigramIndex, query: string, options?:
174
202
  /**
175
203
  * All provinces present in the index, deduplicated and sorted by Thai name.
176
204
  * Backed by `index.byProvince` (O(unique provinces)); falls back to scanning
177
- * `index.records` if an older-shaped index lacks that map.
205
+ * `index.records` if an older-shaped index lacks that map. A missing/junk
206
+ * index returns `[]` rather than throwing.
178
207
  */
179
208
  declare function listProvinces(index: TrigramIndex): ProvinceSummary[];
180
209
  /**
181
210
  * Amphures (districts) within a given province, deduplicated and sorted by
182
211
  * Thai name. Unknown `provinceId` returns `[]`. Backed by `index.byProvince`;
183
212
  * falls back to scanning `index.records` if an older-shaped index lacks it.
213
+ * A missing/junk index returns `[]` rather than throwing.
184
214
  */
185
215
  declare function listAmphures(index: TrigramIndex, provinceId: number): AmphureSummary[];
186
216
  /**
@@ -188,7 +218,8 @@ declare function listAmphures(index: TrigramIndex, provinceId: number): AmphureS
188
218
  * Unknown `amphureId` returns `[]`. Backed by `index.byAmphure`; falls back
189
219
  * to scanning `index.records` if an older-shaped index lacks it. Each
190
220
  * `byAmphure` entry already maps to exactly one tambon record, so no
191
- * deduplication is needed here.
221
+ * deduplication is needed here. A missing/junk index returns `[]` rather
222
+ * than throwing.
192
223
  */
193
224
  declare function listTambons(index: TrigramIndex, amphureId: number): TambonSummary[];
194
225
 
package/dist/index.d.ts CHANGED
@@ -14,6 +14,15 @@ type TrigramIndex = {
14
14
  map: Map<string, Set<number>>;
15
15
  records: ThaiAddressRecord[];
16
16
  zipIndex: Map<string, number[]>;
17
+ /**
18
+ * Zip codes of `zipIndex` in ascending lexical order, with postings
19
+ * parallel in `sortedZipPostings`. Used by `lookupByZipCode` for
20
+ * O(log n + matches) prefix lookup instead of scanning every zip code.
21
+ * Optional: older-shaped or hand-built indexes without these fields fall
22
+ * back to the O(n_zips) scan.
23
+ */
24
+ sortedZipKeys?: string[];
25
+ sortedZipPostings?: number[][];
17
26
  /**
18
27
  * Normalized Thai tambon names, parallel to `records` (same index).
19
28
  * Used by the search ranker for exact/prefix match detection without
@@ -156,6 +165,12 @@ type RawData = {
156
165
  * offending record's `id`, the field, and the actual `typeof` on the first bad
157
166
  * field found. Does not filter soft-deleted rows first — validates every row as
158
167
  * provided.
168
+ *
169
+ * IDs must be unique within each table (duplicates throw, they are not silently
170
+ * discarded — two suggestions sharing an id would resolve to the wrong record).
171
+ * Referential integrity (amphure→province, tambon→amphure) is intentionally NOT
172
+ * enforced: dangling references — including soft-deleted parents — are handled at
173
+ * build time, where the tambon is skipped and reported via `options.onSkip`.
159
174
  */
160
175
  declare function validateRawData(data: RawData): void;
161
176
  declare function buildThaiAddressIndex(data: RawData, options?: BuildIndexOptions): TrigramIndex;
@@ -164,9 +179,22 @@ declare function buildThaiAddressIndex(data: RawData, options?: BuildIndexOption
164
179
  * Look up records by zip code (exact or prefix match). Exact matches are
165
180
  * returned before prefix matches; ties are ordered by ascending zip code.
166
181
  *
182
+ * Uses the build-time `sortedZipKeys`/`sortedZipPostings` arrays with a
183
+ * binary search for the first key ≥ the query, then walks the contiguous
184
+ * prefix range — O(log n_zips + matches). Ascending key order places an
185
+ * exact match before any longer prefix match automatically, so no explicit
186
+ * exact-first sort is needed. Indexes without the sorted arrays (older
187
+ * shape, hand-built) fall back to the original O(n_zips) scan + sort.
188
+ *
189
+ * Returned records are shallow copies: mutating them cannot corrupt the
190
+ * shared index (see `searchThaiAddress`).
191
+ *
167
192
  * Unlike `searchThaiAddress`, results are NOT capped by `options.limit` —
168
193
  * only by `options.zipLimit` (defaults to `Infinity`), since a zip code can
169
194
  * legitimately map to dozens of tambons (e.g. `45000` → 33 records).
195
+ *
196
+ * Malformed runtime input (non-string `zip`, missing index shape) returns
197
+ * `[]` rather than crashing deep inside the library.
170
198
  */
171
199
  declare function lookupByZipCode(index: TrigramIndex, zip: string, options?: SearchOptions): ThaiAddressRecord[];
172
200
  declare function searchThaiAddress(index: TrigramIndex, query: string, options?: SearchOptions): ThaiAddressRecord[];
@@ -174,13 +202,15 @@ declare function searchThaiAddress(index: TrigramIndex, query: string, options?:
174
202
  /**
175
203
  * All provinces present in the index, deduplicated and sorted by Thai name.
176
204
  * Backed by `index.byProvince` (O(unique provinces)); falls back to scanning
177
- * `index.records` if an older-shaped index lacks that map.
205
+ * `index.records` if an older-shaped index lacks that map. A missing/junk
206
+ * index returns `[]` rather than throwing.
178
207
  */
179
208
  declare function listProvinces(index: TrigramIndex): ProvinceSummary[];
180
209
  /**
181
210
  * Amphures (districts) within a given province, deduplicated and sorted by
182
211
  * Thai name. Unknown `provinceId` returns `[]`. Backed by `index.byProvince`;
183
212
  * falls back to scanning `index.records` if an older-shaped index lacks it.
213
+ * A missing/junk index returns `[]` rather than throwing.
184
214
  */
185
215
  declare function listAmphures(index: TrigramIndex, provinceId: number): AmphureSummary[];
186
216
  /**
@@ -188,7 +218,8 @@ declare function listAmphures(index: TrigramIndex, provinceId: number): AmphureS
188
218
  * Unknown `amphureId` returns `[]`. Backed by `index.byAmphure`; falls back
189
219
  * to scanning `index.records` if an older-shaped index lacks it. Each
190
220
  * `byAmphure` entry already maps to exactly one tambon record, so no
191
- * deduplication is needed here.
221
+ * deduplication is needed here. A missing/junk index returns `[]` rather
222
+ * than throwing.
192
223
  */
193
224
  declare function listTambons(index: TrigramIndex, amphureId: number): TambonSummary[];
194
225
 
package/dist/index.js CHANGED
@@ -2,8 +2,13 @@
2
2
  var TONE_MARKS = /[่-๋็์]/g;
3
3
  var ADDRESS_PREFIXES = /^(จังหวัด|อำเภอ|ตำบล|แขวง|เขต|จ\.|อ\.|ต\.|ข\.)/;
4
4
  function normalizeThaiAddressText(input) {
5
- if (!input) return "";
6
- let text = input.trim();
5
+ if (typeof input !== "string") {
6
+ throw new TypeError(
7
+ `[thaizip] normalizeThaiAddressText expected string, got ${input === null ? "null" : typeof input}`
8
+ );
9
+ }
10
+ if (input.length === 0) return "";
11
+ let text = input.normalize("NFC").trim();
7
12
  text = text.replace(ADDRESS_PREFIXES, "").trim();
8
13
  text = text.replace(TONE_MARKS, "");
9
14
  text = text.toLowerCase();
@@ -31,24 +36,57 @@ function extractTrigramsNormalized(text) {
31
36
  function typeErr(table, id, field, expected, value) {
32
37
  return new TypeError(`[thaizip] ${table} ${id}: expected ${expected} for ${field}, got ${typeof value}`);
33
38
  }
39
+ function typeName(value) {
40
+ return value === null ? "null" : typeof value;
41
+ }
34
42
  function validateRawData(data) {
43
+ if (data === null || typeof data !== "object") {
44
+ throw new TypeError(`[thaizip] validateRawData expected a RawData object, got ${typeName(data)}`);
45
+ }
35
46
  const { provinces, amphures, tambons } = data;
47
+ if (!Array.isArray(provinces)) {
48
+ throw new TypeError(`[thaizip] RawData.provinces must be an array, got ${typeName(provinces)}`);
49
+ }
50
+ if (!Array.isArray(amphures)) {
51
+ throw new TypeError(`[thaizip] RawData.amphures must be an array, got ${typeName(amphures)}`);
52
+ }
53
+ if (!Array.isArray(tambons)) {
54
+ throw new TypeError(`[thaizip] RawData.tambons must be an array, got ${typeName(tambons)}`);
55
+ }
56
+ const seenProvinceIds = /* @__PURE__ */ new Set();
36
57
  for (let i = 0; i < provinces.length; i++) {
37
58
  const p = provinces[i];
59
+ if (p === null || typeof p !== "object") {
60
+ throw new TypeError(`[thaizip] province[${i}]: expected object, got ${typeName(p)}`);
61
+ }
38
62
  if (typeof p.id !== "number") throw typeErr("province", p.id, "id", "number", p.id);
63
+ if (seenProvinceIds.has(p.id)) throw new TypeError(`[thaizip] duplicate province id: ${p.id}`);
64
+ seenProvinceIds.add(p.id);
39
65
  if (typeof p.name_th !== "string") throw typeErr("province", p.id, "name_th", "string", p.name_th);
40
66
  if (typeof p.name_en !== "string") throw typeErr("province", p.id, "name_en", "string", p.name_en);
41
67
  }
68
+ const seenAmphureIds = /* @__PURE__ */ new Set();
42
69
  for (let i = 0; i < amphures.length; i++) {
43
70
  const a = amphures[i];
71
+ if (a === null || typeof a !== "object") {
72
+ throw new TypeError(`[thaizip] amphure[${i}]: expected object, got ${typeName(a)}`);
73
+ }
44
74
  if (typeof a.id !== "number") throw typeErr("amphure", a.id, "id", "number", a.id);
75
+ if (seenAmphureIds.has(a.id)) throw new TypeError(`[thaizip] duplicate amphure id: ${a.id}`);
76
+ seenAmphureIds.add(a.id);
45
77
  if (typeof a.name_th !== "string") throw typeErr("amphure", a.id, "name_th", "string", a.name_th);
46
78
  if (typeof a.name_en !== "string") throw typeErr("amphure", a.id, "name_en", "string", a.name_en);
47
79
  if (typeof a.province_id !== "number") throw typeErr("amphure", a.id, "province_id", "number", a.province_id);
48
80
  }
81
+ const seenTambonIds = /* @__PURE__ */ new Set();
49
82
  for (let i = 0; i < tambons.length; i++) {
50
83
  const t = tambons[i];
84
+ if (t === null || typeof t !== "object") {
85
+ throw new TypeError(`[thaizip] tambon[${i}]: expected object, got ${typeName(t)}`);
86
+ }
51
87
  if (typeof t.id !== "number") throw typeErr("tambon", t.id, "id", "number", t.id);
88
+ if (seenTambonIds.has(t.id)) throw new TypeError(`[thaizip] duplicate tambon id: ${t.id}`);
89
+ seenTambonIds.add(t.id);
52
90
  if (typeof t.name_th !== "string") throw typeErr("tambon", t.id, "name_th", "string", t.name_th);
53
91
  if (typeof t.name_en !== "string") throw typeErr("tambon", t.id, "name_en", "string", t.name_en);
54
92
  const zipType = typeof t.zip_code;
@@ -145,7 +183,9 @@ function buildThaiAddressIndex(data, options) {
145
183
  addTrigrams(map, provTrigrams.get(province.id), idx);
146
184
  addTrigrams(map, ampTrigrams.get(amphure.id), idx);
147
185
  }
148
- return { map, records, zipIndex, normTambon, normTambonEn, byProvince, byAmphure };
186
+ const sortedZipKeys = [...zipIndex.keys()].sort();
187
+ const sortedZipPostings = sortedZipKeys.map((z) => zipIndex.get(z));
188
+ return { map, records, zipIndex, sortedZipKeys, sortedZipPostings, normTambon, normTambonEn, byProvince, byAmphure };
149
189
  }
150
190
 
151
191
  // src/core/romanize.ts
@@ -274,19 +314,40 @@ function sortZipMatches(matches, exactZip) {
274
314
  });
275
315
  return matches;
276
316
  }
317
+ function normalizeLimit(value) {
318
+ if (Number.isNaN(value) || value === -Infinity) return 0;
319
+ if (value === Infinity) return value;
320
+ return Math.max(0, Math.floor(value));
321
+ }
277
322
  function lookupByZipCode(index, zip, options) {
278
- if (!index || !zip) return [];
323
+ if (!index || !index.zipIndex || !index.records || typeof zip !== "string") return [];
279
324
  const normalized = zip.trim();
280
325
  if (!ZIP_CODE_RE.test(normalized) || normalized.length < 2) return [];
281
- const zipLimit = options?.zipLimit ?? Infinity;
326
+ const zipLimit = normalizeLimit(options?.zipLimit ?? Infinity);
282
327
  const matches = [];
283
- for (const [z, indices] of index.zipIndex) {
284
- if (z.startsWith(normalized)) {
285
- for (const idx of indices) matches.push(index.records[idx]);
328
+ const keys = index.sortedZipKeys;
329
+ const postings = index.sortedZipPostings;
330
+ if (keys !== void 0 && postings !== void 0) {
331
+ let lo = 0;
332
+ let hi = keys.length;
333
+ while (lo < hi) {
334
+ const mid = lo + hi >> 1;
335
+ if (keys[mid] < normalized) lo = mid + 1;
336
+ else hi = mid;
337
+ }
338
+ for (let i = lo; i < keys.length && keys[i].startsWith(normalized); i++) {
339
+ const indices = postings[i];
340
+ for (let j = 0; j < indices.length; j++) matches.push(index.records[indices[j]]);
341
+ }
342
+ } else {
343
+ for (const [z, indices] of index.zipIndex) {
344
+ if (z.startsWith(normalized)) {
345
+ for (const idx of indices) matches.push(index.records[idx]);
346
+ }
286
347
  }
348
+ sortZipMatches(matches, normalized);
287
349
  }
288
- sortZipMatches(matches, normalized);
289
- return matches.slice(0, zipLimit);
350
+ return matches.slice(0, zipLimit).map((record) => ({ ...record }));
290
351
  }
291
352
  function rankAgainstName(name, query) {
292
353
  if (name === void 0 || name.length === 0) return 0;
@@ -301,10 +362,20 @@ function computeMatchRank(index, idx, query) {
301
362
  const en = rankAgainstName(index.normTambonEn?.[idx], query);
302
363
  return en > th ? en : th;
303
364
  }
365
+ var scratchByIndex = /* @__PURE__ */ new WeakMap();
366
+ function getScratch(index) {
367
+ const n = index.records.length;
368
+ let s = scratchByIndex.get(index);
369
+ if (s === void 0 || s.counts.length < n) {
370
+ s = { counts: new Uint32Array(n), touched: new Int32Array(n) };
371
+ scratchByIndex.set(index, s);
372
+ }
373
+ return s;
374
+ }
304
375
  function searchThaiAddress(index, query, options) {
305
- const limit = options?.limit ?? 10;
376
+ const limit = normalizeLimit(options?.limit ?? 10);
306
377
  const threshold = options?.threshold ?? 0.4;
307
- if (!index || !query) return [];
378
+ if (!index || !index.map || !index.records || typeof query !== "string") return [];
308
379
  if (query.length > 1e3) return [];
309
380
  const normalized = normalizeThaiAddressText(query);
310
381
  if (normalized.length === 0) return [];
@@ -318,17 +389,22 @@ function searchThaiAddress(index, query, options) {
318
389
  if (normalized.length < 3) return [];
319
390
  const searchText = options?.romanizationAliases !== false && HAS_LATIN_RE.test(normalized) ? applyRomanizationAliases(normalized) : normalized;
320
391
  const queryTrigrams = extractTrigramsNormalized(searchText);
321
- const hits = /* @__PURE__ */ new Map();
392
+ const { counts, touched } = getScratch(index);
393
+ let touchedLen = 0;
322
394
  for (const trigram of queryTrigrams) {
323
395
  const candidates = index.map.get(trigram);
324
396
  if (!candidates) continue;
325
397
  for (const idx of candidates) {
326
- hits.set(idx, (hits.get(idx) ?? 0) + 1);
398
+ if (counts[idx] === 0) touched[touchedLen++] = idx;
399
+ counts[idx]++;
327
400
  }
328
401
  }
329
402
  const scored = [];
330
- for (const [idx, count] of hits) {
331
- const score = count / queryTrigrams.size;
403
+ const totalTrigrams = queryTrigrams.size;
404
+ for (let i = 0; i < touchedLen; i++) {
405
+ const idx = touched[i];
406
+ const score = counts[idx] / totalTrigrams;
407
+ counts[idx] = 0;
332
408
  if (score >= threshold) {
333
409
  scored.push({ idx, score, matchRank: computeMatchRank(index, idx, searchText) });
334
410
  }
@@ -346,7 +422,7 @@ function searchThaiAddress(index, query, options) {
346
422
  const rb = index.records[b.idx];
347
423
  return TH_COLLATOR.compare(ra.provinceNameTh, rb.provinceNameTh) || TH_COLLATOR.compare(ra.amphureNameTh, rb.amphureNameTh) || TH_COLLATOR.compare(ra.tambonNameTh, rb.tambonNameTh);
348
424
  });
349
- return window.slice(0, limit).map(({ idx }) => index.records[idx]);
425
+ return window.slice(0, limit).map(({ idx }) => ({ ...index.records[idx] }));
350
426
  }
351
427
 
352
428
  // src/core/enumerate.ts
@@ -355,6 +431,7 @@ function byNameTh(a, b) {
355
431
  return thCollator.compare(a.nameTh, b.nameTh);
356
432
  }
357
433
  function listProvinces(index) {
434
+ if (!index || !index.records) return [];
358
435
  const seen = /* @__PURE__ */ new Map();
359
436
  if (index.byProvince) {
360
437
  for (const [provinceId, recordIndices] of index.byProvince) {
@@ -372,6 +449,7 @@ function listProvinces(index) {
372
449
  return Array.from(seen.values()).sort(byNameTh);
373
450
  }
374
451
  function listAmphures(index, provinceId) {
452
+ if (!index || !index.records) return [];
375
453
  const seen = /* @__PURE__ */ new Map();
376
454
  const addFrom = (r) => {
377
455
  if (!seen.has(r.amphureId)) {
@@ -395,6 +473,7 @@ function listAmphures(index, provinceId) {
395
473
  return Array.from(seen.values()).sort(byNameTh);
396
474
  }
397
475
  function listTambons(index, amphureId) {
476
+ if (!index || !index.records) return [];
398
477
  const toSummary = (r) => ({
399
478
  id: r.tambonId,
400
479
  nameTh: r.tambonNameTh,