mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* merge master: rebump v0.41.25.0 → v0.41.27.0 (queue collision) Master shipped v0.41.25.0 (#1538 batched sync deletes) and v0.41.26.0 (#1571 dream --source fix) while this branch was in flight. Conflict resolution rebumps to the next available slot. - VERSION: 0.41.25.0 → 0.41.27.0 - package.json: synced - CHANGELOG.md: my v0.41.27.0 entry placed above master's v0.41.26.0 and v0.41.25.0; in-entry version references updated 0.41.25.0 → 0.41.27.0 and forward-references bumped to v0.41.28+. - TODOS.md: kept master's v0.41.20.x section + my v0.41.27.0+ follow-ups No source-file conflicts during the merge. * feat(diagnostics): db-disconnect audit + doctor surface (v0.41.27.0) Instruments every db.disconnect() and PostgresEngine.disconnect() call with a JSONL audit record so the next user-reported #1570 cycle gives us the offender's caller stack instead of the symptomatic "No database connection" error. Audit shape (~/.gbrain/audit/db-disconnect-YYYY-Www.jsonl): {ts, engine_kind, connection_style, caller_stack[], command, pid} - src/core/audit/db-disconnect-audit.ts (NEW): the audit writer, built on the v0.40.4.0 createAuditWriter cathedral. Captures a 6-frame stack via new Error().stack so the offender is readable without spending stderr noise. - src/core/db.ts: logDbDisconnect call at the top of disconnect() (best-effort; never blocks the real teardown). - src/core/postgres-engine.ts: same instrumentation in PostgresEngine.disconnect() — distinguishes 'module' vs 'instance' connection_style so we can tell legitimate worker-pool teardowns apart from the load-bearing module-singleton class. - src/commands/doctor.ts: extends batch_retry_health to surface 24h disconnect count + most-recent caller stack. Warns when the caller frame isn't a known CLI-exit frame (e.g. cli.ts's finally block at the end of an op-dispatch). This is the diagnostic that tells v0.41.28+ where to apply the real ownership fix. - test/db-disconnect-audit.test.ts: unit coverage for the audit writer + caller-stack capture + JSONL shape. - test/e2e/db-singleton-shared-recovery.test.ts: real-Postgres regression that exercises the singleton-null path end-to-end. Refs #1570 * feat(retry): self-heal on null singleton — closes #1570 symptom (v0.41.27.0) withRetry gains an opt-in reconnect callback that fires between the isRetryableConnError classification and the inter-attempt sleep. PostgresEngine.batchRetry injects this.reconnect() — race-safe via the existing _reconnecting guard, handles module and instance pools. Closes the production loss reported in #1570: dream cycles on Supabase no longer drop ~150 link rows per cycle when the singleton goes null mid-batch. The retry now rebuilds the connection between attempts so the second try has somewhere to write to. - src/core/retry.ts: WithRetryOpts gains `reconnect?: () => Promise<void>`. Awaited in the catch branch. onRetry is also now awaited (back-compat- safe: every existing in-tree caller is a sync arrow). Reconnect failures propagate as the real cause — replaces the symptomatic "No database connection" error with whatever the connect() throw was, so operators see the truth. - src/core/postgres-engine.ts:batchRetry — injects `reconnect: () => this.reconnect()`. Covers all 9 batch-retry call sites (addLinksBatch, addTimelineEntriesBatch, upsertChunks, plus the 6 caller-supplied auditSite labels in extract / sync / reindex). - test/core/retry-reconnect.test.ts: 8 hermetic cases pinning the contract — reconnect fires before sleep, only on retryable errors, back-compat when omitted, signal-aborted bypasses reconnect, onRetry is awaited, full success path end-to-end. The deeper bug (who's calling disconnect mid-cycle) is left unaddressed in this commit by design — the diagnostic instrumentation in the prior commit will tell us in the next production run. Refs #1570 * feat(facts): drainPending() + CLI await before disconnect (v0.41.27.0) Closes the silent 'No database connection' tail-end errors after gbrain capture / put_page: the facts:absorb fire-and-forget queue sometimes outlived the CLI process's connection lifetime, so absorb attempts after engine.disconnect() landed in stderr as the GBrainError shape. - src/core/facts/queue.ts: new drainPending({timeout: 1000}) method distinct from shutdown(). Stops accepting new enqueues, awaits in-flight settle, bounded by timeout, returns count of unfinished. Semantically different from shutdown() (which aborts in-flight) so the symptom — drop work that hasn't started yet but let in-flight work finish — matches what CLI exit actually needs. - src/cli.ts: op-dispatch finally block awaits the drain BEFORE engine.disconnect(). Bounded 1s. Opt-out env GBRAIN_NO_FACTS_DRAIN for callers that don't enqueue (keeps fast-exit paths fast). Mirrors the v0.41.8.0 awaitPendingLastRetrievedWrites pattern. - test/facts-queue-drain-pending.test.ts: 6 hermetic cases — empty drain returns immediately, single in-flight settles, timeout bounds wait, shutdown-after-drain is idempotent, post-drain enqueues are dropped, signal-aborted skips waiting. Refs #1570 * docs: update project documentation for v0.41.27.0 README.md: added troubleshooting entry for the v0.41.27.0 retry-reconnect + facts:absorb drain fix (closes #1570), pointing operators at `gbrain doctor --json` to find the offending disconnect caller. CLAUDE.md: extended `src/core/retry.ts` entry with the new optional `reconnect` callback (v0.41.27.0); added two new Key Files entries for `src/core/audit/db-disconnect-audit.ts` (the diagnostic half of the "instrument first, fix later" pivot) and `FactsQueue.drainPending`; extended `doctor.ts:checkBatchRetryHealth` entry with the in-place extension that surfaces 24h disconnect-call count. llms-full.txt: regenerated to absorb CLAUDE.md edits. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore: rebump v0.41.27.0 → v0.41.28.0 (queue collision with #1573) Master shipped v0.41.27.0 (#1573 git-aware sync_freshness) claiming the same slot. Rebump to the next available version. - VERSION + package.json → 0.41.28.0 - CHANGELOG.md: my entry header + in-entry refs 0.41.27.0 → 0.41.28.0 - TODOS.md: my #1570 follow-up section header + body refs bumped * test: pin gateway in put-page-provenance + embedding-dim-check (CI shard fix) Both files failed on CI shards 1 and 8 under the cross-file gateway-state leak class (CLAUDE.md "Test-isolation lint and helpers"). The v0.41.28.0 merge reshuffled the weight-based shard bin-packing, landing a gateway-mutating sibling ahead of these two victims in the same `bun test` process. Mechanism: - put-page-provenance: put_page embeds via the gateway. A sibling left the gateway configured with OpenAI + the CI placeholder `sk-test` (captured at configureGateway time, survives the withEnv restore as cached gateway state). put_page's embed then fired against live OpenAI and 401'd. The bunfig legacy-embedding preload's beforeEach only re-applies legacy when the gateway was RESET — it does NOT correct a sibling that configured a different LIVE config. - embedding-dim-check: initSchema builds the content_chunks vector column at the gateway's configured dim. A sibling leaking ZE/1280 made the column 1280-d, so `expect(dims).toBe(1536)` failed. Fix (victim-side pinning, the escape hatch the preload documents): - Both: configure the gateway explicitly in beforeAll BEFORE initSchema (OpenAI/1536), resetGateway() in afterAll so neither leaks onward. - put-page-provenance also stubs the embed transport via __setEmbedTransportForTests so embed is deterministic and offline; a dummy OPENAI_API_KEY is supplied in the gateway env because instantiateEmbedding builds the OpenAI client (key check) BEFORE the stubbed transport is reached — the stub then intercepts the actual call so the key never leaves the process. Verified: CI shards 1 (1337 pass) + 8 (905 pass) green with OPENAI_API_KEY unset, plus adversarial sibling orderings (gateway.test / doctor-ze-checks preceding). Typecheck + check-test-isolation clean. --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
336 lines
13 KiB
TypeScript
336 lines
13 KiB
TypeScript
/**
|
|
* v0.28.5 (A4) — Existing-brain dimension-mismatch detection unit tests.
|
|
*
|
|
* Pairs with `gbrain init` and `gbrain doctor`'s loud-failure paths. Validates
|
|
* that:
|
|
* 1. readContentChunksEmbeddingDim correctly reports null on a fresh brain.
|
|
* 2. After initSchema, it returns the actual templated dim (1536 default).
|
|
* 3. embeddingMismatchMessage produces a recipe that explicitly drops the
|
|
* HNSW index, alters the column, wipes embeddings, and conditionally
|
|
* reindexes — codex's #8 finding from plan review.
|
|
*/
|
|
|
|
import { test, expect, describe, beforeAll, afterAll } from 'bun:test';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import {
|
|
readContentChunksEmbeddingDim,
|
|
embeddingMismatchMessage,
|
|
resolveSchemaEmbeddingDim,
|
|
resolveSchemaMultimodalDim,
|
|
PGVECTOR_COLUMN_MAX_DIMS,
|
|
} from '../src/core/embedding-dim-check.ts';
|
|
import { configureGateway, resetGateway } from '../src/core/ai/gateway.ts';
|
|
|
|
// Canonical pattern: single engine per file, init once, disconnect once.
|
|
// The two tests below diverge in whether they want a migrated brain or a
|
|
// pre-initSchema brain — handled by inline reset / second-engine instead of
|
|
// resetting in beforeEach (keeps the migrated state cached for the LATEST case).
|
|
let engine: PGLiteEngine;
|
|
|
|
beforeAll(async () => {
|
|
// Hermeticity guard (cross-file gateway-state leak class — see CLAUDE.md
|
|
// "Test-isolation lint and helpers"). initSchema builds the
|
|
// content_chunks vector column at the gateway's configured dim. The
|
|
// bunfig preload pins OpenAI/1536, but its beforeEach only re-applies
|
|
// legacy when the gateway was RESET (throws) — it does NOT correct a
|
|
// sibling that configured a different LIVE dim (e.g. ZE/1280) and never
|
|
// reset. Under weight-based shard bin-packing, such a sibling can run
|
|
// first, so pin 1536 explicitly here BEFORE initSchema (this is exactly
|
|
// the "call configureGateway() in your own beforeAll" escape hatch the
|
|
// preload documents). Reset in afterAll so we don't leak 1536 onward.
|
|
configureGateway({
|
|
embedding_model: 'openai:text-embedding-3-large',
|
|
embedding_dimensions: 1536,
|
|
env: { ...process.env },
|
|
});
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
resetGateway();
|
|
});
|
|
|
|
describe('readContentChunksEmbeddingDim', () => {
|
|
test('returns dims from a migrated brain (1536d via legacy-embedding preload)', async () => {
|
|
// v0.37 fix wave: the canonical gateway default is now 1280 (ZE).
|
|
// However, `bunfig.toml` preloads `test/helpers/legacy-embedding-preload.ts`
|
|
// which configures the gateway to OpenAI/1536 BEFORE any test runs.
|
|
// This preserves the 20+ test files with hardcoded 1536-d
|
|
// Float32Array fixtures. So initSchema() under tests produces a
|
|
// 1536-d column.
|
|
//
|
|
// New v0.37 tests that need to assert the ZE/1280 default can call
|
|
// configureGateway() explicitly in their own beforeAll, which
|
|
// overrides the preload.
|
|
const result = await readContentChunksEmbeddingDim(engine);
|
|
expect(result.exists).toBe(true);
|
|
expect(result.dims).toBe(1536);
|
|
}, 30000);
|
|
|
|
test('returns { exists: false, dims: null } on a fresh brain (no initSchema)', async () => {
|
|
// One-off engine for the fresh-brain case. Never call initSchema so
|
|
// content_chunks doesn't exist yet. Cleaned up at end of test.
|
|
const fresh = new PGLiteEngine();
|
|
await fresh.connect({});
|
|
try {
|
|
const result = await readContentChunksEmbeddingDim(fresh);
|
|
expect(result.exists).toBe(false);
|
|
expect(result.dims).toBeNull();
|
|
} finally {
|
|
await fresh.disconnect();
|
|
}
|
|
}, 30000);
|
|
});
|
|
|
|
describe('embeddingMismatchMessage', () => {
|
|
test('Postgres branch inlines all four recipe steps for HNSW-eligible dims', () => {
|
|
const msg = embeddingMismatchMessage({
|
|
currentDims: 1536,
|
|
requestedDims: 768,
|
|
requestedModel: 'nomic-embed-text',
|
|
source: 'init',
|
|
engineKind: 'postgres',
|
|
});
|
|
expect(msg).toContain('vector(1536)');
|
|
expect(msg).toContain('vector(768)');
|
|
expect(msg).toContain('DROP INDEX IF EXISTS idx_chunks_embedding');
|
|
expect(msg).toContain('ALTER TABLE content_chunks ALTER COLUMN embedding TYPE vector(768)');
|
|
expect(msg).toContain('UPDATE content_chunks SET embedding = NULL');
|
|
expect(msg).toContain('CREATE INDEX IF NOT EXISTS idx_chunks_embedding');
|
|
expect(msg).toContain('docs/embedding-migrations.md');
|
|
});
|
|
|
|
test('Postgres branch skips HNSW recreate when requested dims exceed pgvector cap', () => {
|
|
// Codex finding #8: 2048d (Voyage 4 Large) cannot be HNSW-indexed in pgvector.
|
|
// The recipe must NOT instruct a CREATE INDEX HNSW for that dim.
|
|
const msg = embeddingMismatchMessage({
|
|
currentDims: 1536,
|
|
requestedDims: 2048,
|
|
requestedModel: 'voyage-4-large',
|
|
source: 'init',
|
|
engineKind: 'postgres',
|
|
});
|
|
expect(msg).toContain('vector(2048)');
|
|
expect(msg).toContain('Skip reindex');
|
|
expect(msg).toContain("exceeds pgvector's HNSW cap");
|
|
// The HNSW CREATE INDEX line must NOT appear in the 2048d recipe.
|
|
expect(msg).not.toContain('CREATE INDEX IF NOT EXISTS idx_chunks_embedding\n ON content_chunks USING hnsw');
|
|
});
|
|
|
|
test('source: doctor uses a different header than source: init', () => {
|
|
const initMsg = embeddingMismatchMessage({ currentDims: 1536, requestedDims: 768, source: 'init', engineKind: 'postgres' });
|
|
const doctorMsg = embeddingMismatchMessage({ currentDims: 1536, requestedDims: 768, source: 'doctor', engineKind: 'postgres' });
|
|
expect(initMsg).toContain('Refusing to silently re-template');
|
|
expect(doctorMsg).toContain('Embedding dimension mismatch detected');
|
|
});
|
|
|
|
// v0.37 fix wave Lane D.1: PGLite branch uses wipe-and-reinit recipe
|
|
// because PGLite can't ALTER vector column types.
|
|
test('PGLite branch uses wipe-and-reinit, not ALTER COLUMN', () => {
|
|
const msg = embeddingMismatchMessage({
|
|
currentDims: 1536,
|
|
requestedDims: 1280,
|
|
requestedModel: 'zeroentropyai:zembed-1',
|
|
source: 'init',
|
|
engineKind: 'pglite',
|
|
databasePath: '/tmp/test-brain.pglite',
|
|
});
|
|
expect(msg).toContain('vector(1536)');
|
|
expect(msg).toContain('vector(1280)');
|
|
expect(msg).toContain('mv /tmp/test-brain.pglite /tmp/test-brain.pglite.bak');
|
|
expect(msg).toContain('gbrain init --pglite --embedding-model zeroentropyai:zembed-1 --embedding-dimensions 1280');
|
|
expect(msg).toContain('PGLite cannot ALTER vector column types');
|
|
// Must NOT contain the Postgres-only SQL recipe.
|
|
expect(msg).not.toContain('ALTER TABLE content_chunks ALTER COLUMN');
|
|
expect(msg).not.toContain('DROP INDEX IF EXISTS idx_chunks_embedding');
|
|
});
|
|
|
|
test('PGLite branch falls back to default database path when omitted', () => {
|
|
const msg = embeddingMismatchMessage({
|
|
currentDims: 1536,
|
|
requestedDims: 1280,
|
|
source: 'init',
|
|
engineKind: 'pglite',
|
|
});
|
|
// Default falls back to gbrainPath('brain.pglite').
|
|
expect(msg).toMatch(/mv .+brain\.pglite .+brain\.pglite\.bak/);
|
|
});
|
|
|
|
test('PGLite branch must NOT recommend `gbrain config set embedding_model` (no-op after Lane C.2)', () => {
|
|
const msg = embeddingMismatchMessage({
|
|
currentDims: 1536,
|
|
requestedDims: 1280,
|
|
requestedModel: 'zeroentropyai:zembed-1',
|
|
source: 'doctor',
|
|
engineKind: 'pglite',
|
|
});
|
|
// The pre-v0.37 recipe pointed at `gbrain config set embedding_model X`
|
|
// which is a no-op after C.2. Recipe must point at init instead.
|
|
expect(msg).not.toContain('gbrain config set embedding_model');
|
|
expect(msg).not.toContain('gbrain config set embedding_dimensions');
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// v0.37.x — D11 + D12 preflight resolvers
|
|
// ============================================================================
|
|
|
|
describe('resolveSchemaEmbeddingDim', () => {
|
|
test('OpenAI text-embedding-3-large resolves at default 1536', () => {
|
|
const got = resolveSchemaEmbeddingDim({ embedding_model: 'openai:text-embedding-3-large' });
|
|
expect(got).toEqual({
|
|
ok: true,
|
|
dim: 1536,
|
|
model: 'openai:text-embedding-3-large',
|
|
provider: 'openai',
|
|
recipeDefault: 1536,
|
|
});
|
|
});
|
|
|
|
test('ZeroEntropy zembed-1 resolves at recipe default', () => {
|
|
const got = resolveSchemaEmbeddingDim({ embedding_model: 'zeroentropyai:zembed-1' });
|
|
expect(got.ok).toBe(true);
|
|
if (got.ok) {
|
|
expect(got.provider).toBe('zeroentropyai');
|
|
expect(got.model).toBe('zeroentropyai:zembed-1');
|
|
expect(got.dim).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
test('ZeroEntropy Matryoshka explicit dim (1280) accepted', () => {
|
|
const got = resolveSchemaEmbeddingDim({
|
|
embedding_model: 'zeroentropyai:zembed-1',
|
|
embedding_dimensions: 1280,
|
|
});
|
|
expect(got.ok).toBe(true);
|
|
if (got.ok) expect(got.dim).toBe(1280);
|
|
});
|
|
|
|
test('ZeroEntropy Matryoshka invalid dim (1024) rejected — 1024 is Voyage step, not ZE', () => {
|
|
const got = resolveSchemaEmbeddingDim({
|
|
embedding_model: 'zeroentropyai:zembed-1',
|
|
embedding_dimensions: 1024,
|
|
});
|
|
expect(got.ok).toBe(false);
|
|
if (!got.ok) expect(got.error).toMatch(/does not support custom dimensions 1024|only emits/);
|
|
});
|
|
|
|
test('OpenAI text-3-large rejects 2048 (not in declared dims_options)', () => {
|
|
const got = resolveSchemaEmbeddingDim({
|
|
embedding_model: 'openai:text-embedding-3-large',
|
|
embedding_dimensions: 2048,
|
|
});
|
|
expect(got.ok).toBe(false);
|
|
if (!got.ok) expect(got.error).toMatch(/rejects custom dimensions 2048|does not support custom dimensions/);
|
|
});
|
|
|
|
test('OpenAI text-3-large accepts 768 (declared in recipe dims_options)', () => {
|
|
// text-embedding-3-large declares dims_options including 768.
|
|
const got = resolveSchemaEmbeddingDim({
|
|
embedding_model: 'openai:text-embedding-3-large',
|
|
embedding_dimensions: 768,
|
|
});
|
|
expect(got.ok).toBe(true);
|
|
if (got.ok) expect(got.dim).toBe(768);
|
|
});
|
|
|
|
test('unknown provider rejected with provider list hint', () => {
|
|
const got = resolveSchemaEmbeddingDim({ embedding_model: 'notarealprovider:foo' });
|
|
expect(got.ok).toBe(false);
|
|
if (!got.ok) expect(got.error).toMatch(/unknown provider/i);
|
|
});
|
|
|
|
test('missing colon rejected', () => {
|
|
const got = resolveSchemaEmbeddingDim({ embedding_model: 'openai' });
|
|
expect(got.ok).toBe(false);
|
|
});
|
|
|
|
test('negative dim rejected', () => {
|
|
const got = resolveSchemaEmbeddingDim({
|
|
embedding_model: 'openai:text-embedding-3-large',
|
|
embedding_dimensions: -100,
|
|
});
|
|
expect(got.ok).toBe(false);
|
|
if (!got.ok) expect(got.error).toMatch(/positive integer/);
|
|
});
|
|
|
|
test('zero dim rejected', () => {
|
|
const got = resolveSchemaEmbeddingDim({
|
|
embedding_model: 'openai:text-embedding-3-large',
|
|
embedding_dimensions: 0,
|
|
});
|
|
expect(got.ok).toBe(false);
|
|
});
|
|
|
|
test('non-integer dim rejected', () => {
|
|
const got = resolveSchemaEmbeddingDim({
|
|
embedding_model: 'openai:text-embedding-3-large',
|
|
embedding_dimensions: 1536.5,
|
|
});
|
|
expect(got.ok).toBe(false);
|
|
});
|
|
|
|
test('dim exceeding pgvector column cap rejected', () => {
|
|
const got = resolveSchemaEmbeddingDim({
|
|
embedding_model: 'openai:text-embedding-3-large',
|
|
embedding_dimensions: PGVECTOR_COLUMN_MAX_DIMS + 1,
|
|
});
|
|
expect(got.ok).toBe(false);
|
|
if (!got.ok) expect(got.error).toMatch(/exceed pgvector's column cap/);
|
|
});
|
|
|
|
test('regression: bug-reporter scenario — OpenAI auto-pick resolves at 1536', () => {
|
|
const got = resolveSchemaEmbeddingDim({ embedding_model: 'openai:text-embedding-3-large' });
|
|
expect(got.ok).toBe(true);
|
|
if (got.ok) {
|
|
expect(got.dim).toBe(1536);
|
|
expect(got.model).toBe('openai:text-embedding-3-large');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('resolveSchemaMultimodalDim', () => {
|
|
test('voyage voyage-multimodal-3 accepted', () => {
|
|
const got = resolveSchemaMultimodalDim({ embedding_multimodal_model: 'voyage:voyage-multimodal-3' });
|
|
expect(got.ok).toBe(true);
|
|
if (got.ok) {
|
|
expect(got.provider).toBe('voyage');
|
|
expect(got.dim).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
test('OpenAI text-embedding-3-large rejected — not multimodal', () => {
|
|
const got = resolveSchemaMultimodalDim({
|
|
embedding_multimodal_model: 'openai:text-embedding-3-large',
|
|
});
|
|
expect(got.ok).toBe(false);
|
|
if (!got.ok) expect(got.error).toMatch(/does not support multimodal/);
|
|
});
|
|
|
|
test('voyage text-only model (voyage-3-large) rejected via allow-list', () => {
|
|
const got = resolveSchemaMultimodalDim({
|
|
embedding_multimodal_model: 'voyage:voyage-3-large',
|
|
});
|
|
expect(got.ok).toBe(false);
|
|
if (!got.ok) expect(got.error).toMatch(/not in provider "voyage"'s multimodal allow-list/);
|
|
});
|
|
|
|
test('unknown provider rejected', () => {
|
|
const got = resolveSchemaMultimodalDim({
|
|
embedding_multimodal_model: 'notarealprovider:foo',
|
|
});
|
|
expect(got.ok).toBe(false);
|
|
});
|
|
|
|
test('dim above pgvector cap rejected', () => {
|
|
const got = resolveSchemaMultimodalDim({
|
|
embedding_multimodal_model: 'voyage:voyage-multimodal-3',
|
|
embedding_multimodal_dimensions: PGVECTOR_COLUMN_MAX_DIMS + 1,
|
|
});
|
|
expect(got.ok).toBe(false);
|
|
});
|
|
});
|