') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); fix(metadata,objectql,metadata-protocol): require a missing-table error to name the table that was read by claude[bot] · Pull Request #13437 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .changeset/missing-table-must-name-the-read-table.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
"@objectstack/metadata": minor
"@objectstack/objectql": patch
"@objectstack/metadata-protocol": patch
---

fix(metadata,objectql,metadata-protocol): require a missing-table error to name the table that was READ (#13324)

`isMissingTableError` answers the one question that licenses a fail-soft caller
to treat an empty result as the truth: "did this read fail because the table has
not been provisioned yet?". It matched the *shape* of the dialect phrase and
never asked WHICH table the phrase names.

Measured on a real libsql database: a view whose base table is gone fails with
`no such table: main.<base>` when the view itself is read. The phrase matches,
so a read of a relation that **exists and may be backed by rows** was classified
benign, and every fail-soft consumer on that path — `probeInstallOrganizations`,
`resolveFileReferences`, `seedAutonumber`, the cascade-delete dependents probe,
`DatabaseLoader`, `SeedLoaderService`, the `sys_metadata` overlay reads —
computed its answer from data it never read. That is a false "benign", the
direction the module's own docblock calls far more expensive than a false
"real".

The predicate now takes the object the caller was reading and refuses the
benign verdict when the phrase names a different relation. Shape alone cannot
separate the two cases: measured, a view over a missing base table and a
genuine missing table the caller qualified produce byte-identical messages, so
the read's name is a parameter rather than another regex.

The parameter is **optional** — omitting it reproduces the previous behaviour
exactly, so no external caller of `@objectstack/metadata/errors` changes. Every
in-repo call site now passes it. The comparison folds away schema/database
qualifiers, the legacy `namespace__short` prefix and case, so every shape
recognised before for a genuine missing table (sqlite `no such table: X`,
Postgres `relation "x" does not exist`, MySQL `table "x" doesn't exist`,
`unknown table`, the SQLSTATE and errno limbs) still answers benign.
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,9 +84,17 @@ function emptyRegistry(items: Record<string, any> = {}) {
* An engine whose every read REJECTS with `error` — the shape of a metadata
* store the protocol cannot reach.
*/
function engineThatCannotBeRead(error: () => unknown, registryItems: Record<string, any> = {}) {
function engineThatCannotBeRead(
error: (object: string) => unknown,
registryItems: Record<string, any> = {},
) {
// [#13324] The object reaches the factory, so a missing-table fault can be
// phrased for the table that was actually read. A driver never names one
// table while failing a read of another, and `isMissingTableError` now
// tells those two apart — a fixed phrase would make this fixture assert
// the benign verdict for a fault no driver produces here.
const reject = vi.fn(async (object: string, query?: EngineFindOneQueryInput) => {
assertEngineFindOnePredicate(object, query); throw error(); });
assertEngineFindOnePredicate(object, query); throw error(object); });
return {
registry: emptyRegistry(registryItems),
find: reject,
Expand All@@ -104,8 +112,8 @@ function engineWithRows(rows: any[] = [], registryItems: Record<string, any> = {
}

/** The real driver phrasings for "the table has not been provisioned yet". */
const missingTable = () =>
Object.assign(new Error('SQLITE_ERROR: no such table: sys_metadata'), { code: 'SQLITE_ERROR' });
const missingTable = (object = 'sys_metadata') =>
Object.assign(new Error(`SQLITE_ERROR: no such table: ${object}`), { code: 'SQLITE_ERROR' });

/** An outage: the rows may well exist and simply were not seen. */
const connectionRefused = () =>
Expand Down
40 changes: 24 additions & 16 deletions packages/metadata-protocol/src/protocol.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5792,8 +5792,16 @@ export class ObjectStackProtocolImplementation implements
* @returns normally ONLY for the benign case, licensing the caller to treat
* the overlay as absent.
*/
private rethrowUnlessMetadataStoreUnprovisioned(error: unknown): void {
if (isMissingTableError(error)) return;
private rethrowUnlessMetadataStoreUnprovisioned(error: unknown, readObject: string): void {
// [#13324] `readObject` is REQUIRED, deliberately. This helper serves
// callers that read four different tables (`sys_metadata`,
// `sys_metadata_audit`, `sys_metadata_commit`, `sys_metadata_history`),
// so a default would silently answer about the wrong one for three of
// them — measured, not hypothetical: the first draft of this repair
// hardcoded `sys_metadata` and turned `diffMetaItem`'s genuinely
// unprovisioned `sys_metadata_history` into a loud failure. A required
// parameter makes the compiler ask the question at every new call site.
if (isMissingTableError(error, readObject)) return;
// [#12536] CLASSIFY, do not assume. A read can fail because the store
// is unreachable OR because a metadata app's hook refused it in its
// own words — see {@link metadataReadFailureError}.
Expand DownExpand Up@@ -6398,7 +6406,7 @@ export class ObjectStackProtocolImplementation implements
// answer with whatever we already have. Any other read failure
// means overlay rows may exist and were not seen — serving the
// registry-only set would report them as never declared.
this.rethrowUnlessMetadataStoreUnprovisioned(error);
this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata');
}

// ADR-0033 draft-overlay preview: when the caller opts in (admin-gated
Expand DownExpand Up@@ -6457,7 +6465,7 @@ export class ObjectStackProtocolImplementation implements
// the active result "unchanged" is a lie to a caller that asked
// for a draft preview: it renders the published world while the
// pending edits it asked to see were never read.
this.rethrowUnlessMetadataStoreUnprovisioned(error);
this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata');
}
}

Expand DownExpand Up@@ -6686,7 +6694,7 @@ export class ObjectStackProtocolImplementation implements
// [#5532] Falling through to the active read here would answer
// "there is no draft for this item" from a read that never
// reached the table the drafts live in.
this.rethrowUnlessMetadataStoreUnprovisioned(error);
this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata');
}
}

Expand DownExpand Up@@ -6751,7 +6759,7 @@ export class ObjectStackProtocolImplementation implements
// let a storage outage arrive at the client as `not found` (active
// read) or `NO_DRAFT` (draft read) — both of them claims about
// authorship, made from a read that never happened.
this.rethrowUnlessMetadataStoreUnprovisioned(error);
this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata');
}

// Draft reads stop here — they intentionally do NOT fall through
Expand DownExpand Up@@ -7176,7 +7184,7 @@ export class ObjectStackProtocolImplementation implements
// overlay row, so `overlay: null` / `effective = code` IS the truth
// and first boot still renders the code layer.
// See {@link rethrowUnlessMetadataStoreUnprovisioned}.
this.rethrowUnlessMetadataStoreUnprovisioned(error);
this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata');
}

// [#4513] `effective` is documented above as "what `getMetaItem` would
Expand DownExpand Up@@ -7516,7 +7524,7 @@ export class ObjectStackProtocolImplementation implements
// The second cause the old comment named — a host engine with no
// `find` — is decided by the precondition probe above the `try`, so
// it never reaches here and this arm has exactly ONE benign cause.
this.rethrowUnlessMetadataStoreUnprovisioned(err);
this.rethrowUnlessMetadataStoreUnprovisioned(err, 'sys_metadata_audit');
console.warn(
`[Protocol] auditMetaItem read failed for ${request.type}/${request.name}: ${err?.message ?? err}`,
);
Expand DownExpand Up@@ -10337,7 +10345,7 @@ export class ObjectStackProtocolImplementation implements
//
// No new response field and no new error code — the caller
// receives the read's own failure, envelope intact.
if (isMissingTableError(error)) continue;
if (isMissingTableError(error, obj.name)) continue;
throw error;
}
}
Expand DownExpand Up@@ -12042,7 +12050,7 @@ export class ObjectStackProtocolImplementation implements
// `rollback` / `delete` now fail with 503 when the lock state
// cannot be read, instead of proceeding as if unlocked. Refusing
// one uncertain write beats performing one that had to be refused.
this.rethrowUnlessMetadataStoreUnprovisioned(error);
this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata');
}
return { lock: 'none', lockReason: undefined, lockSource: undefined };
}
Expand DownExpand Up@@ -13428,7 +13436,7 @@ export class ObjectStackProtocolImplementation implements
const row = await this.engine.findOne('sys_metadata', { where: { type } });
return row != null;
} catch (error) {
this.rethrowUnlessMetadataStoreUnprovisioned(error);
this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata');
return false;
}
}
Expand DownExpand Up@@ -16335,7 +16343,7 @@ export class ObjectStackProtocolImplementation implements
//
// No new error code and no new response field: the caller
// receives the read's own failure, envelope intact.
if (!isMissingTableError(error)) throw error;
if (!isMissingTableError(error, 'sys_metadata')) throw error;
commitItems.push({ type: d.type, name: d.name, existedBefore: false, prevVersion: null });
}
}
Expand DownExpand Up@@ -17784,7 +17792,7 @@ export class ObjectStackProtocolImplementation implements
// the turn is unrevertible is a separate question (a response-field
// change the #8896 ruling forbids for this family) and deliberately
// NOT decided here.
if (isMissingTableError(error)) {
if (isMissingTableError(error, 'sys_metadata_commit')) {
if (!this.commitStoreUnprovisionedNoted) {
this.commitStoreUnprovisionedNoted = true;
console.info(
Expand DownExpand Up@@ -17935,7 +17943,7 @@ export class ObjectStackProtocolImplementation implements
} catch (error) {
// [#5980] Benign (the table has not been provisioned) falls through;
// everything else is a read that did not happen and leaves as a 503.
this.rethrowUnlessMetadataStoreUnprovisioned(error);
this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata_commit');
return [];
}
}
Expand DownExpand Up@@ -18933,7 +18941,7 @@ export class ObjectStackProtocolImplementation implements
// ⛔ A `historyUnavailable: true` response key (the card's option B) was
// DECLINED in the same ruling — a new published key with no consumer, on
// the manual floor. Do not reintroduce it as "more informative".
this.rethrowUnlessMetadataStoreUnprovisioned(error);
this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata_history');
}
const byVersion = new Map<number, Record<string, unknown> | null>();
for (const r of histRows) byVersion.set(r.version, r.body);
Expand DownExpand Up@@ -19684,7 +19692,7 @@ export class ObjectStackProtocolImplementation implements
// `error` names what the outage COSTS and how to fix it. Keeping
// the technical line here at `warn` is what lets the consumer's
// line stay the single loud statement of consequence.
if (!isMissingTableError(e)) {
if (!isMissingTableError(e, 'sys_metadata')) {
storeUnavailable = true;
console.warn(
`[Protocol] DB hydration skipped: ${e instanceof Error ? e.message : String(e)}`,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -65,6 +65,13 @@ function createLogger() {
* re-wrap a driver error. `findCalls` records that the read really ran, which
* is what turns "the seed proceeded" into "the seed proceeded AND the injected
* throw fired".
*
* [#13324] It also accepts a FUNCTION of the object name, because the loader
* reads more than one table on this path (`sys_organization` for the sole-org
* probe, then the seeded object) and a missing-table fault names the table it
* was raised for. A single fixed value phrased for one of them is a fault that
* no driver produces for the other, and `isMissingTableError` now tells those
* apart — so the per-object form is what keeps this fixture faithful.
*/
function createEngine() {
const store: Record<string, StoreRow[]> = {};
Expand All@@ -75,7 +82,7 @@ function createEngine() {
const engine = {
find: vi.fn(async (objectName: string, query?: { where?: Record<string, unknown>; limit?: number }) => {
findCalls.push(objectName);
if (failFind !== null) throw failFind;
if (failFind !== null) throw typeof failFind === 'function' ? failFind(objectName) : failFind;
let records = store[objectName] ?? [];
if (query?.where) {
const where = query.where;
Expand DownExpand Up@@ -134,7 +141,7 @@ function createEngine() {
engine,
store,
findCalls,
failReadsWith: (error: unknown) => { failFind = error; },
failReadsWith: (error: unknown | ((objectName: string) => unknown)) => { failFind = error; },
stopFailingReads: () => { failFind = null; },
};
}
Expand DownExpand Up@@ -180,8 +187,8 @@ const seedOf = (mode: string, records: Array<Record<string, unknown>>) => [{
/** The real driver phrasings, verbatim. */
const connectionDropped = () =>
Object.assign(new Error('connection terminated unexpectedly'), { code: 'ECONNRESET' });
const tableNotProvisioned = () =>
Object.assign(new Error('SQLITE_ERROR: no such table: my_app_widget'), { code: 'SQLITE_ERROR' });
const tableNotProvisioned = (objectName = 'my_app_widget') =>
Object.assign(new Error(`SQLITE_ERROR: no such table: ${objectName}`), { code: 'SQLITE_ERROR' });

/** Capture a rejection without letting a resolve pass silently. */
async function rejection(run: () => Promise<unknown>): Promise<{ code?: string; message?: string } & Record<string, unknown>> {
Expand DownExpand Up@@ -315,7 +322,7 @@ describe('[#8896] seed loader — an existing-records read that FAILED is not "n

it('an UNPROVISIONED table is truthful emptiness: the seed writes its rows', async () => {
const { engine, store, findCalls, failReadsWith } = createEngine();
failReadsWith(tableNotProvisioned());
failReadsWith((objectName: string) => tableNotProvisioned(objectName));

const result = await new SeedLoaderService(engine, createMetadata(), createLogger()).load({
seeds: seedOf('upsert', [{ name: 'Fresh', sku: 'W-A' }]),
Expand All@@ -333,8 +340,8 @@ describe('[#8896] seed loader — an existing-records read that FAILED is not "n

it('an UNPROVISIONED table in the postgres phrasing (42P01) is benign too', async () => {
const { engine, findCalls, failReadsWith } = createEngine();
failReadsWith(Object.assign(
new Error('relation "my_app_widget" does not exist'),
failReadsWith((objectName: string) => Object.assign(
new Error(`relation "${objectName}" does not exist`),
{ code: '42P01' },
));

Expand Down
4 changes: 2 additions & 2 deletions packages/metadata-protocol/src/seed-loader.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1428,7 +1428,7 @@ export class SeedLoaderService implements ISeedLoaderService {
// seen. It propagates, envelope intact: the seed run fails loudly instead
// of writing a batch of rows nobody will be able to see. No new error code
// and no new result field — the caller receives the read's own failure.
if (!isMissingTableError(error)) throw error;
if (!isMissingTableError(error, 'sys_organization')) throw error;
}
return undefined;
}
Expand DownExpand Up@@ -2476,7 +2476,7 @@ export class SeedLoaderService implements ISeedLoaderService {
// write plan from data it never read. No new error code and no new
// result field — the caller receives the read's own failure, envelope
// intact, and the seed's existing error accounting reports it.
if (!isMissingTableError(error)) throw error;
if (!isMissingTableError(error, objectName)) throw error;
}
return map;
}
Expand Down
4 changes: 3 additions & 1 deletion packages/metadata-protocol/src/sys-metadata-repository.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1880,7 +1880,9 @@ export class SysMetadataRepository implements MetadataRepository {
subject: string,
): 1 {
// Benign — and only benign: a fresh DB has no row to be inconsistent with.
if (isMissingTableError(error)) return 1;
// [#13324] Both callers read `this.historyTable`, so a failure naming any
// other relation is not evidence that THIS one is empty.
if (isMissingTableError(error, this.historyTable)) return 1;

if (!this.historyCounterFailureReported) {
this.historyCounterFailureReported = true;
Expand Down
8 changes: 6 additions & 2 deletions packages/metadata/src/loaders/database-loader.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -331,7 +331,7 @@ export class DatabaseLoader implements MetadataLoader {
} catch (error) {
// Benign — and ONLY benign: there is no table, therefore no row, so
// numbering from 1 cannot collide with anything.
if (isMissingTableError(error)) return 1;
if (isMissingTableError(error, this.historyTableName)) return 1;
throw error;
}
}
Expand DownExpand Up@@ -760,7 +760,11 @@ export class DatabaseLoader implements MetadataLoader {
* with its empty value.
*/
private rethrowUnlessTableUnprovisioned(error: unknown): void {
if (isMissingTableError(error)) return;
// [#13324] Every caller of this helper reads `this.tableName`, so that is
// the relation whose emptiness they are about to trust — a failure naming
// any OTHER relation (a view over a dropped base table) is not evidence
// about it and stays loud.
if (isMissingTableError(error, this.tableName)) return;
throw error;
}

Expand Down
Loading
Loading