deploy: 755c0b7
Browse files- .xtap-deployment.json +1 -1
- package.json +1 -0
- scripts/deploy-space.sh +66 -27
- shared/src/enrichment.ts +2 -0
- shared/src/index.ts +1 -0
- space/package.json +1 -0
- space/src/config.ts +5 -1
- space/src/dataset-token.ts +71 -54
- space/src/dataset.ts +157 -13
- space/src/durable-index.ts +887 -0
- space/src/enrich-command.ts +41 -14
- space/src/index-command-main.ts +9 -0
- space/src/index-command.ts +50 -0
- space/src/server.ts +121 -59
- space/tests/config.test.ts +3 -1
- space/tests/dataset-token.test.ts +61 -72
- space/tests/dataset.test.ts +56 -1
- space/tests/durable-index.test.ts +416 -0
- space/tests/helpers.ts +1 -0
.xtap-deployment.json
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
{
|
| 2 |
-
"source_revision": "
|
| 3 |
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"source_revision": "755c0b7c154599a59ec569189ebfa2fb70e13353"
|
| 3 |
}
|
package.json
CHANGED
|
@@ -13,6 +13,7 @@
|
|
| 13 |
"setup": "npm run build --workspace shared && npm run build --workspace setup && npm run start --workspace setup",
|
| 14 |
"update": "npm run build --workspace shared && npm run build --workspace setup && npm run start --workspace setup -- update",
|
| 15 |
"doctor": "npm run --silent build --workspace shared && npm run --silent build --workspace setup && npm run --silent start --workspace setup -- doctor",
|
|
|
|
| 16 |
"format": "prettier --check .",
|
| 17 |
"format:write": "prettier --write .",
|
| 18 |
"lint": "eslint .",
|
|
|
|
| 13 |
"setup": "npm run build --workspace shared && npm run build --workspace setup && npm run start --workspace setup",
|
| 14 |
"update": "npm run build --workspace shared && npm run build --workspace setup && npm run start --workspace setup -- update",
|
| 15 |
"doctor": "npm run --silent build --workspace shared && npm run --silent build --workspace setup && npm run --silent start --workspace setup -- doctor",
|
| 16 |
+
"index:bootstrap": "npm run build --workspace shared && npm run build --workspace space && npm run index:bootstrap --workspace space",
|
| 17 |
"format": "prettier --check .",
|
| 18 |
"format:write": "prettier --write .",
|
| 19 |
"lint": "eslint .",
|
scripts/deploy-space.sh
CHANGED
|
@@ -1,64 +1,103 @@
|
|
| 1 |
#!/usr/bin/env bash
|
| 2 |
# Deploy this repo to a Hugging Face Docker Space.
|
| 3 |
#
|
| 4 |
-
# Requires an
|
| 5 |
-
#
|
|
|
|
|
|
|
| 6 |
#
|
| 7 |
# Usage:
|
| 8 |
-
# scripts/deploy-space.sh <namespace>
|
| 9 |
-
# SPACE_REPO=<ns>/<name> DATASET_REPO=<ns>/<name>
|
|
|
|
| 10 |
set -euo pipefail
|
| 11 |
|
| 12 |
NAMESPACE="${1:-${NAMESPACE:-}}"
|
| 13 |
SPACE_REPO="${SPACE_REPO:-${NAMESPACE:?usage: deploy-space.sh <namespace>}/xtap-pool}"
|
| 14 |
DATASET_REPO="${DATASET_REPO:-${NAMESPACE}/xtap-pool-data}"
|
|
|
|
| 15 |
ALLOWED_USERS="${ALLOWED_USERS:-osolmaz}"
|
|
|
|
|
|
|
| 16 |
|
| 17 |
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
| 18 |
STAGE="$(mktemp -d)"
|
| 19 |
-
|
|
|
|
| 20 |
|
| 21 |
-
echo "==> Creating
|
| 22 |
hf repos create "$DATASET_REPO" --repo-type dataset --private 2>/dev/null || true
|
|
|
|
| 23 |
# The Space itself is public: a private Space would put HF's repo-access gate
|
| 24 |
-
# in front of the app
|
| 25 |
-
# Space collaborators. All data access is enforced in-app (OAuth allowlist +
|
| 26 |
-
# pool tokens); anonymous visitors only see the sign-in page. To keep the
|
| 27 |
-
# Space page private instead, add every friend as a Space collaborator.
|
| 28 |
hf repos create "$SPACE_REPO" --repo-type space --space-sdk docker 2>/dev/null || true
|
| 29 |
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
-
echo "==>
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
echo "==> Setting Space secrets and variables"
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
import secrets
|
| 41 |
-
import sys
|
| 42 |
|
| 43 |
from huggingface_hub import HfApi
|
| 44 |
|
| 45 |
-
space
|
| 46 |
api = HfApi()
|
| 47 |
variables = dict(api.get_space_variables(space))
|
| 48 |
-
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
# Secrets cannot be listed back, so a sentinel variable marks that they were
|
| 51 |
-
# set once. Never rotate silently: rotating logs everyone out
|
| 52 |
# every extension.
|
| 53 |
if "SECRETS_INITIALIZED" not in variables:
|
| 54 |
for name in ("POOL_SIGNING_SECRET", "SESSION_SECRET"):
|
| 55 |
api.add_space_secret(space, name, secrets.token_hex(32))
|
| 56 |
api.add_space_variable(space, "SECRETS_INITIALIZED", "1")
|
| 57 |
-
print("Set
|
| 58 |
-
print("Remaining manual steps:")
|
| 59 |
-
print(f" 1. Create a fine-grained token with read/write access to {dataset} only,")
|
| 60 |
-
print(f" then: python3 -c \"from huggingface_hub import HfApi; HfApi().add_space_secret('{space}', 'HF_TOKEN', '<token>')\"")
|
| 61 |
-
print(f" 2. Optionally import history: scripts/seed-dataset.sh {dataset} <hf-username> ~/Downloads/xtap")
|
| 62 |
PY
|
| 63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
echo "==> Done. Space: https://huggingface.co/spaces/$SPACE_REPO"
|
|
|
|
| 1 |
#!/usr/bin/env bash
|
| 2 |
# Deploy this repo to a Hugging Face Docker Space.
|
| 3 |
#
|
| 4 |
+
# Requires an authenticated Hugging Face CLI session that can create repos and
|
| 5 |
+
# Buckets. XTAP_STORAGE_TOKEN must be a fine-grained token with read/write
|
| 6 |
+
# access to only the target dataset and index Bucket. Supplying it explicitly
|
| 7 |
+
# authorizes this script to install it as the Space HF_TOKEN secret.
|
| 8 |
#
|
| 9 |
# Usage:
|
| 10 |
+
# XTAP_STORAGE_TOKEN=... scripts/deploy-space.sh <namespace>
|
| 11 |
+
# SPACE_REPO=<ns>/<name> DATASET_REPO=<ns>/<name> INDEX_BUCKET=<ns>/<name> \
|
| 12 |
+
# XTAP_STORAGE_TOKEN=... scripts/deploy-space.sh
|
| 13 |
set -euo pipefail
|
| 14 |
|
| 15 |
NAMESPACE="${1:-${NAMESPACE:-}}"
|
| 16 |
SPACE_REPO="${SPACE_REPO:-${NAMESPACE:?usage: deploy-space.sh <namespace>}/xtap-pool}"
|
| 17 |
DATASET_REPO="${DATASET_REPO:-${NAMESPACE}/xtap-pool-data}"
|
| 18 |
+
INDEX_BUCKET="${INDEX_BUCKET:-${NAMESPACE}/xtap-pool-bucket}"
|
| 19 |
ALLOWED_USERS="${ALLOWED_USERS:-osolmaz}"
|
| 20 |
+
LLM_MODEL="${LLM_MODEL:-zai-org/GLM-5.2:fireworks-ai}"
|
| 21 |
+
TAXONOMY_VERSION="${TAXONOMY_VERSION:-1}"
|
| 22 |
|
| 23 |
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
| 24 |
STAGE="$(mktemp -d)"
|
| 25 |
+
INDEX_WORK="$(mktemp -d)"
|
| 26 |
+
trap 'rm -rf "$STAGE" "$INDEX_WORK"' EXIT
|
| 27 |
|
| 28 |
+
echo "==> Creating storage and Space resources (idempotent)"
|
| 29 |
hf repos create "$DATASET_REPO" --repo-type dataset --private 2>/dev/null || true
|
| 30 |
+
hf buckets create "$INDEX_BUCKET" --private --exist-ok
|
| 31 |
# The Space itself is public: a private Space would put HF's repo-access gate
|
| 32 |
+
# in front of the app. Anonymous visitors still see only the sign-in page.
|
|
|
|
|
|
|
|
|
|
| 33 |
hf repos create "$SPACE_REPO" --repo-type space --space-sdk docker 2>/dev/null || true
|
| 34 |
|
| 35 |
+
if [[ -z "${XTAP_STORAGE_TOKEN:-}" ]]; then
|
| 36 |
+
cat >&2 <<EOF
|
| 37 |
+
XTAP_STORAGE_TOKEN is required before deployment.
|
| 38 |
+
Create a fine-grained token with read/write access to exactly:
|
| 39 |
+
dataset: $DATASET_REPO
|
| 40 |
+
Bucket: $INDEX_BUCKET
|
| 41 |
+
Before rerunning, optionally import history so the first durable generation
|
| 42 |
+
contains it:
|
| 43 |
+
scripts/seed-dataset.sh $DATASET_REPO <hf-username> ~/Downloads/xtap
|
| 44 |
+
Then rerun this command with XTAP_STORAGE_TOKEN set. The script will bootstrap
|
| 45 |
+
the durable index and install the same value as the Space HF_TOKEN.
|
| 46 |
+
EOF
|
| 47 |
+
exit 2
|
| 48 |
+
fi
|
| 49 |
|
| 50 |
+
echo "==> Bootstrapping and verifying the durable index"
|
| 51 |
+
DATA_DIR="$INDEX_WORK" \
|
| 52 |
+
DATASET_REPO="$DATASET_REPO" \
|
| 53 |
+
INDEX_BUCKET="$INDEX_BUCKET" \
|
| 54 |
+
HF_TOKEN="$XTAP_STORAGE_TOKEN" \
|
| 55 |
+
LLM_MODEL="$LLM_MODEL" \
|
| 56 |
+
TAXONOMY_VERSION="$TAXONOMY_VERSION" \
|
| 57 |
+
npm --prefix "$ROOT" run index:bootstrap
|
| 58 |
|
| 59 |
echo "==> Setting Space secrets and variables"
|
| 60 |
+
SPACE_REPO="$SPACE_REPO" \
|
| 61 |
+
DATASET_REPO="$DATASET_REPO" \
|
| 62 |
+
INDEX_BUCKET="$INDEX_BUCKET" \
|
| 63 |
+
ALLOWED_USERS="$ALLOWED_USERS" \
|
| 64 |
+
LLM_MODEL="$LLM_MODEL" \
|
| 65 |
+
TAXONOMY_VERSION="$TAXONOMY_VERSION" \
|
| 66 |
+
XTAP_STORAGE_TOKEN="$XTAP_STORAGE_TOKEN" \
|
| 67 |
+
python3 <<'PY'
|
| 68 |
+
import os
|
| 69 |
import secrets
|
|
|
|
| 70 |
|
| 71 |
from huggingface_hub import HfApi
|
| 72 |
|
| 73 |
+
space = os.environ["SPACE_REPO"]
|
| 74 |
api = HfApi()
|
| 75 |
variables = dict(api.get_space_variables(space))
|
| 76 |
+
for name in (
|
| 77 |
+
"DATASET_REPO",
|
| 78 |
+
"INDEX_BUCKET",
|
| 79 |
+
"ALLOWED_USERS",
|
| 80 |
+
"LLM_MODEL",
|
| 81 |
+
"TAXONOMY_VERSION",
|
| 82 |
+
):
|
| 83 |
+
api.add_space_variable(space, name, os.environ[name])
|
| 84 |
+
api.add_space_secret(space, "HF_TOKEN", os.environ["XTAP_STORAGE_TOKEN"])
|
| 85 |
# Secrets cannot be listed back, so a sentinel variable marks that they were
|
| 86 |
+
# set once. Never rotate silently: rotating logs everyone out or disconnects
|
| 87 |
# every extension.
|
| 88 |
if "SECRETS_INITIALIZED" not in variables:
|
| 89 |
for name in ("POOL_SIGNING_SECRET", "SESSION_SECRET"):
|
| 90 |
api.add_space_secret(space, name, secrets.token_hex(32))
|
| 91 |
api.add_space_variable(space, "SECRETS_INITIALIZED", "1")
|
| 92 |
+
print("Set storage variables, contract variables, and the scoped HF_TOKEN.")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
PY
|
| 94 |
|
| 95 |
+
echo "==> Staging Space contents"
|
| 96 |
+
git -C "$ROOT" archive HEAD | tar -x -C "$STAGE"
|
| 97 |
+
cp "$ROOT/space/hf-space-README.md" "$STAGE/README.md"
|
| 98 |
+
rm -rf "$STAGE/docs" "$STAGE/extension"
|
| 99 |
+
|
| 100 |
+
echo "==> Uploading to $SPACE_REPO"
|
| 101 |
+
hf upload "$SPACE_REPO" "$STAGE" . --repo-type space --commit-message "deploy: $(git -C "$ROOT" rev-parse --short HEAD)"
|
| 102 |
+
|
| 103 |
echo "==> Done. Space: https://huggingface.co/spaces/$SPACE_REPO"
|
shared/src/enrichment.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { z } from "zod";
|
|
| 3 |
import { dayKey } from "./tweet.js";
|
| 4 |
import type { Tweet } from "./tweet.js";
|
| 5 |
|
|
|
|
|
|
|
| 6 |
/** One preset taxonomy entry; the description steers the classifier. */
|
| 7 |
export const labelConfigSchema = z.object({
|
| 8 |
name: z.string().min(1),
|
|
|
|
| 3 |
import { dayKey } from "./tweet.js";
|
| 4 |
import type { Tweet } from "./tweet.js";
|
| 5 |
|
| 6 |
+
export const DEFAULT_ENRICHMENT_MODEL = "zai-org/GLM-5.2:fireworks-ai";
|
| 7 |
+
|
| 8 |
/** One preset taxonomy entry; the description steers the classifier. */
|
| 9 |
export const labelConfigSchema = z.object({
|
| 10 |
name: z.string().min(1),
|
shared/src/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ export {
|
|
| 9 |
type TweetValidationResult,
|
| 10 |
} from "./tweet.js";
|
| 11 |
export {
|
|
|
|
| 12 |
labelConfigSchema,
|
| 13 |
enrichmentRowSchema,
|
| 14 |
parseEnrichmentRow,
|
|
|
|
| 9 |
type TweetValidationResult,
|
| 10 |
} from "./tweet.js";
|
| 11 |
export {
|
| 12 |
+
DEFAULT_ENRICHMENT_MODEL,
|
| 13 |
labelConfigSchema,
|
| 14 |
enrichmentRowSchema,
|
| 15 |
parseEnrichmentRow,
|
space/package.json
CHANGED
|
@@ -9,6 +9,7 @@
|
|
| 9 |
"start": "node dist/src/server.js",
|
| 10 |
"enrich": "node dist/src/enrich-command-main.js",
|
| 11 |
"enrich:job": "node dist/src/enrich-job-main.js",
|
|
|
|
| 12 |
"format": "prettier --check .",
|
| 13 |
"lint": "eslint .",
|
| 14 |
"typecheck": "tsc -p tsconfig.typecheck.json --noEmit",
|
|
|
|
| 9 |
"start": "node dist/src/server.js",
|
| 10 |
"enrich": "node dist/src/enrich-command-main.js",
|
| 11 |
"enrich:job": "node dist/src/enrich-job-main.js",
|
| 12 |
+
"index:bootstrap": "node dist/src/index-command-main.js",
|
| 13 |
"format": "prettier --check .",
|
| 14 |
"lint": "eslint .",
|
| 15 |
"typecheck": "tsc -p tsconfig.typecheck.json --noEmit",
|
space/src/config.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
|
|
| 1 |
import { z } from "zod";
|
| 2 |
|
| 3 |
const configSchema = z.object({
|
| 4 |
PORT: z.coerce.number().int().positive().default(7860),
|
| 5 |
DATA_DIR: z.string().default(".data"),
|
| 6 |
DATASET_REPO: z.string().min(1),
|
|
|
|
| 7 |
HF_TOKEN: z.string().min(1),
|
| 8 |
INFERENCE_TOKEN: z.string().min(1).optional(),
|
| 9 |
POOL_SIGNING_SECRET: z.string().min(32),
|
|
@@ -26,7 +28,7 @@ const configSchema = z.object({
|
|
| 26 |
ENRICH_OUTPUT_TOKEN_USD: z.coerce.number().nonnegative().optional(),
|
| 27 |
ENRICH_MAX_DISCARDED_ASSIGNMENTS_PER_UNIT: z.coerce.number().nonnegative().optional(),
|
| 28 |
ENRICH_DISCARDED_ASSIGNMENT_RATE_MIN_UNITS: z.coerce.number().int().positive().optional(),
|
| 29 |
-
LLM_MODEL: z.string().min(1).default(
|
| 30 |
TAXONOMY_VERSION: z.coerce.number().int().min(1).default(1),
|
| 31 |
});
|
| 32 |
|
|
@@ -34,6 +36,7 @@ export type SpaceConfig = {
|
|
| 34 |
port: number;
|
| 35 |
dataDir: string;
|
| 36 |
datasetRepo: string;
|
|
|
|
| 37 |
hfToken: string;
|
| 38 |
inferenceToken?: string;
|
| 39 |
poolSigningSecret: string;
|
|
@@ -93,6 +96,7 @@ export function loadConfig(env: Record<string, string | undefined>): SpaceConfig
|
|
| 93 |
port: parsed.PORT,
|
| 94 |
dataDir: parsed.DATA_DIR,
|
| 95 |
datasetRepo: parsed.DATASET_REPO,
|
|
|
|
| 96 |
hfToken: parsed.HF_TOKEN,
|
| 97 |
...(parsed.INFERENCE_TOKEN === undefined ? {} : { inferenceToken: parsed.INFERENCE_TOKEN }),
|
| 98 |
poolSigningSecret: parsed.POOL_SIGNING_SECRET,
|
|
|
|
| 1 |
+
import { DEFAULT_ENRICHMENT_MODEL } from "@xtap-pool/shared";
|
| 2 |
import { z } from "zod";
|
| 3 |
|
| 4 |
const configSchema = z.object({
|
| 5 |
PORT: z.coerce.number().int().positive().default(7860),
|
| 6 |
DATA_DIR: z.string().default(".data"),
|
| 7 |
DATASET_REPO: z.string().min(1),
|
| 8 |
+
INDEX_BUCKET: z.string().min(1),
|
| 9 |
HF_TOKEN: z.string().min(1),
|
| 10 |
INFERENCE_TOKEN: z.string().min(1).optional(),
|
| 11 |
POOL_SIGNING_SECRET: z.string().min(32),
|
|
|
|
| 28 |
ENRICH_OUTPUT_TOKEN_USD: z.coerce.number().nonnegative().optional(),
|
| 29 |
ENRICH_MAX_DISCARDED_ASSIGNMENTS_PER_UNIT: z.coerce.number().nonnegative().optional(),
|
| 30 |
ENRICH_DISCARDED_ASSIGNMENT_RATE_MIN_UNITS: z.coerce.number().int().positive().optional(),
|
| 31 |
+
LLM_MODEL: z.string().min(1).default(DEFAULT_ENRICHMENT_MODEL),
|
| 32 |
TAXONOMY_VERSION: z.coerce.number().int().min(1).default(1),
|
| 33 |
});
|
| 34 |
|
|
|
|
| 36 |
port: number;
|
| 37 |
dataDir: string;
|
| 38 |
datasetRepo: string;
|
| 39 |
+
indexBucket: string;
|
| 40 |
hfToken: string;
|
| 41 |
inferenceToken?: string;
|
| 42 |
poolSigningSecret: string;
|
|
|
|
| 96 |
port: parsed.PORT,
|
| 97 |
dataDir: parsed.DATA_DIR,
|
| 98 |
datasetRepo: parsed.DATASET_REPO,
|
| 99 |
+
indexBucket: parsed.INDEX_BUCKET,
|
| 100 |
hfToken: parsed.HF_TOKEN,
|
| 101 |
...(parsed.INFERENCE_TOKEN === undefined ? {} : { inferenceToken: parsed.INFERENCE_TOKEN }),
|
| 102 |
poolSigningSecret: parsed.POOL_SIGNING_SECRET,
|
space/src/dataset-token.ts
CHANGED
|
@@ -10,27 +10,32 @@ const TARGET_PERMISSIONS = new Set([
|
|
| 10 |
const READ_PERMISSION = "repo.content.read";
|
| 11 |
const WRITE_PERMISSIONS = ["repo.content.write", "repo.write"] as const;
|
| 12 |
|
|
|
|
|
|
|
| 13 |
export type DatasetCredentialReadiness =
|
| 14 |
| { credential: "ok" }
|
| 15 |
| { credential: "invalid"; error: string }
|
| 16 |
| { credential: "unknown"; error: string };
|
| 17 |
|
| 18 |
-
/** Verify the Space HF_TOKEN can read and write
|
| 19 |
export async function checkDatasetCredential(params: {
|
| 20 |
token: string;
|
| 21 |
datasetRepo: string;
|
|
|
|
| 22 |
fetchFn?: typeof fetch;
|
| 23 |
}): Promise<DatasetCredentialReadiness> {
|
| 24 |
try {
|
| 25 |
const response = await (params.fetchFn ?? fetch)("https://huggingface.co/api/whoami-v2", {
|
| 26 |
headers: { authorization: `Bearer ${params.token}` },
|
| 27 |
});
|
| 28 |
-
if (!response.ok)
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
| 32 |
if (errors.length > 0) return { credential: "invalid", error: errors.join(" ") };
|
| 33 |
-
return await
|
| 34 |
} catch (error) {
|
| 35 |
return { credential: "unknown", error: errorMessage(error) };
|
| 36 |
}
|
|
@@ -40,26 +45,37 @@ export function datasetCredentialOk(status: DatasetCredentialReadiness): boolean
|
|
| 40 |
return status.credential === "ok";
|
| 41 |
}
|
| 42 |
|
| 43 |
-
async function
|
| 44 |
token: string;
|
| 45 |
datasetRepo: string;
|
|
|
|
| 46 |
fetchFn?: typeof fetch;
|
| 47 |
}): Promise<DatasetCredentialReadiness> {
|
| 48 |
-
const
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
}
|
| 61 |
|
| 62 |
-
function
|
| 63 |
const root = asRecord(payload);
|
| 64 |
const accessToken = asRecord(asRecord(root["auth"])["accessToken"]);
|
| 65 |
const fineGrained = asRecord(accessToken["fineGrained"]);
|
|
@@ -69,12 +85,15 @@ function datasetTokenErrors(payload: unknown, datasetRepo: string): string[] {
|
|
| 69 |
? []
|
| 70 |
: [`HF_TOKEN role is '${role || "unknown"}', expected fine-grained.`];
|
| 71 |
errors.push(...globalPermissionErrors(fineGrained));
|
| 72 |
-
const
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
| 78 |
}
|
| 79 |
return errors;
|
| 80 |
}
|
|
@@ -87,53 +106,51 @@ function globalPermissionErrors(fineGrained: JsonObject): string[] {
|
|
| 87 |
|
| 88 |
function scopedPermissionErrors(
|
| 89 |
fineGrained: JsonObject,
|
| 90 |
-
|
| 91 |
errors: string[],
|
| 92 |
-
): Set<string> {
|
| 93 |
-
const
|
|
|
|
|
|
|
| 94 |
for (const scope of array(fineGrained["scoped"])) {
|
| 95 |
-
collectScopePermissions(asRecord(scope),
|
| 96 |
}
|
| 97 |
-
return
|
| 98 |
}
|
| 99 |
|
| 100 |
function collectScopePermissions(
|
| 101 |
scope: JsonObject,
|
| 102 |
-
|
| 103 |
-
|
| 104 |
errors: string[],
|
| 105 |
): void {
|
| 106 |
const entity = asRecord(scope["entity"]);
|
|
|
|
| 107 |
for (const permission of strings(scope["permissions"])) {
|
| 108 |
-
if (
|
| 109 |
-
recordTargetPermission(permission, targetPermissions, errors);
|
| 110 |
-
} else {
|
| 111 |
errors.push(
|
| 112 |
-
`Unexpected permission outside
|
| 113 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
}
|
| 115 |
}
|
| 116 |
}
|
| 117 |
|
| 118 |
-
function
|
| 119 |
-
|
| 120 |
-
targetPermissions: Set<string>,
|
| 121 |
-
errors: string[],
|
| 122 |
-
): void {
|
| 123 |
-
if (TARGET_PERMISSIONS.has(permission)) targetPermissions.add(permission);
|
| 124 |
-
else errors.push(`Unexpected permission on HF_TOKEN: ${permission}.`);
|
| 125 |
-
}
|
| 126 |
-
|
| 127 |
-
function matchesDataset(entity: JsonObject, datasetRepo: string): boolean {
|
| 128 |
return (
|
| 129 |
-
|
| 130 |
-
entityCandidates(entity).some(
|
|
|
|
|
|
|
| 131 |
);
|
| 132 |
}
|
| 133 |
|
| 134 |
-
function
|
| 135 |
-
|
| 136 |
-
return type === "dataset" || type === "datasets";
|
| 137 |
}
|
| 138 |
|
| 139 |
function entityCandidates(entity: JsonObject): readonly string[] {
|
|
@@ -150,8 +167,8 @@ function entityLabel(entity: JsonObject): string {
|
|
| 150 |
return `${kind}:${name}`;
|
| 151 |
}
|
| 152 |
|
| 153 |
-
function
|
| 154 |
-
return value.replace(/^datasets\//, "");
|
| 155 |
}
|
| 156 |
|
| 157 |
function tokenStatusError(tokenName: "HF_TOKEN", status: number): DatasetCredentialReadiness {
|
|
|
|
| 10 |
const READ_PERMISSION = "repo.content.read";
|
| 11 |
const WRITE_PERMISSIONS = ["repo.content.write", "repo.write"] as const;
|
| 12 |
|
| 13 |
+
type StorageTarget = { kind: "bucket" | "dataset"; name: string };
|
| 14 |
+
|
| 15 |
export type DatasetCredentialReadiness =
|
| 16 |
| { credential: "ok" }
|
| 17 |
| { credential: "invalid"; error: string }
|
| 18 |
| { credential: "unknown"; error: string };
|
| 19 |
|
| 20 |
+
/** Verify the Space HF_TOKEN can read and write the configured dataset and index Bucket only. */
|
| 21 |
export async function checkDatasetCredential(params: {
|
| 22 |
token: string;
|
| 23 |
datasetRepo: string;
|
| 24 |
+
indexBucket: string;
|
| 25 |
fetchFn?: typeof fetch;
|
| 26 |
}): Promise<DatasetCredentialReadiness> {
|
| 27 |
try {
|
| 28 |
const response = await (params.fetchFn ?? fetch)("https://huggingface.co/api/whoami-v2", {
|
| 29 |
headers: { authorization: `Bearer ${params.token}` },
|
| 30 |
});
|
| 31 |
+
if (!response.ok) return tokenStatusError("HF_TOKEN", response.status);
|
| 32 |
+
const targets: readonly StorageTarget[] = [
|
| 33 |
+
{ kind: "dataset", name: params.datasetRepo },
|
| 34 |
+
{ kind: "bucket", name: params.indexBucket },
|
| 35 |
+
];
|
| 36 |
+
const errors = storageTokenErrors(await response.json(), targets);
|
| 37 |
if (errors.length > 0) return { credential: "invalid", error: errors.join(" ") };
|
| 38 |
+
return await checkStorageReads(params);
|
| 39 |
} catch (error) {
|
| 40 |
return { credential: "unknown", error: errorMessage(error) };
|
| 41 |
}
|
|
|
|
| 45 |
return status.credential === "ok";
|
| 46 |
}
|
| 47 |
|
| 48 |
+
async function checkStorageReads(params: {
|
| 49 |
token: string;
|
| 50 |
datasetRepo: string;
|
| 51 |
+
indexBucket: string;
|
| 52 |
fetchFn?: typeof fetch;
|
| 53 |
}): Promise<DatasetCredentialReadiness> {
|
| 54 |
+
const fetchFn = params.fetchFn ?? fetch;
|
| 55 |
+
const probes = [
|
| 56 |
+
{
|
| 57 |
+
url: `https://huggingface.co/datasets/${params.datasetRepo}/resolve/main/config/pool.json`,
|
| 58 |
+
label: "private-dataset download",
|
| 59 |
+
},
|
| 60 |
+
{
|
| 61 |
+
url: `https://huggingface.co/api/buckets/${params.indexBucket}`,
|
| 62 |
+
label: "private-Bucket read",
|
| 63 |
+
},
|
| 64 |
+
];
|
| 65 |
+
for (const probe of probes) {
|
| 66 |
+
const response = await fetchFn(probe.url, {
|
| 67 |
+
headers: { authorization: `Bearer ${params.token}` },
|
| 68 |
+
});
|
| 69 |
+
if (response.ok || response.status === 404) continue;
|
| 70 |
+
const error = `Hugging Face rejected a direct ${probe.label} using HF_TOKEN (${String(response.status)}).`;
|
| 71 |
+
return response.status === 401 || response.status === 403
|
| 72 |
+
? { credential: "invalid", error }
|
| 73 |
+
: { credential: "unknown", error };
|
| 74 |
+
}
|
| 75 |
+
return { credential: "ok" };
|
| 76 |
}
|
| 77 |
|
| 78 |
+
function storageTokenErrors(payload: unknown, targets: readonly StorageTarget[]): string[] {
|
| 79 |
const root = asRecord(payload);
|
| 80 |
const accessToken = asRecord(asRecord(root["auth"])["accessToken"]);
|
| 81 |
const fineGrained = asRecord(accessToken["fineGrained"]);
|
|
|
|
| 85 |
? []
|
| 86 |
: [`HF_TOKEN role is '${role || "unknown"}', expected fine-grained.`];
|
| 87 |
errors.push(...globalPermissionErrors(fineGrained));
|
| 88 |
+
const permissionsByTarget = scopedPermissionErrors(fineGrained, targets, errors);
|
| 89 |
+
for (const target of targets) {
|
| 90 |
+
const permissions = permissionsByTarget.get(targetKey(target)) ?? new Set<string>();
|
| 91 |
+
if (!permissions.has(READ_PERMISSION)) {
|
| 92 |
+
errors.push(`HF_TOKEN must include ${READ_PERMISSION} on ${target.name}.`);
|
| 93 |
+
}
|
| 94 |
+
if (!WRITE_PERMISSIONS.some((permission) => permissions.has(permission))) {
|
| 95 |
+
errors.push(`HF_TOKEN must include ${WRITE_PERMISSIONS.join(" or ")} on ${target.name}.`);
|
| 96 |
+
}
|
| 97 |
}
|
| 98 |
return errors;
|
| 99 |
}
|
|
|
|
| 106 |
|
| 107 |
function scopedPermissionErrors(
|
| 108 |
fineGrained: JsonObject,
|
| 109 |
+
targets: readonly StorageTarget[],
|
| 110 |
errors: string[],
|
| 111 |
+
): Map<string, Set<string>> {
|
| 112 |
+
const permissionsByTarget = new Map(
|
| 113 |
+
targets.map((target) => [targetKey(target), new Set<string>()]),
|
| 114 |
+
);
|
| 115 |
for (const scope of array(fineGrained["scoped"])) {
|
| 116 |
+
collectScopePermissions(asRecord(scope), targets, permissionsByTarget, errors);
|
| 117 |
}
|
| 118 |
+
return permissionsByTarget;
|
| 119 |
}
|
| 120 |
|
| 121 |
function collectScopePermissions(
|
| 122 |
scope: JsonObject,
|
| 123 |
+
targets: readonly StorageTarget[],
|
| 124 |
+
permissionsByTarget: Map<string, Set<string>>,
|
| 125 |
errors: string[],
|
| 126 |
): void {
|
| 127 |
const entity = asRecord(scope["entity"]);
|
| 128 |
+
const target = targets.find((candidate) => matchesTarget(entity, candidate));
|
| 129 |
for (const permission of strings(scope["permissions"])) {
|
| 130 |
+
if (target === undefined) {
|
|
|
|
|
|
|
| 131 |
errors.push(
|
| 132 |
+
`Unexpected permission outside configured storage on HF_TOKEN: ${permission} on ${entityLabel(entity)}.`,
|
| 133 |
);
|
| 134 |
+
} else if (!TARGET_PERMISSIONS.has(permission)) {
|
| 135 |
+
errors.push(`Unexpected permission on HF_TOKEN: ${permission}.`);
|
| 136 |
+
} else {
|
| 137 |
+
permissionsByTarget.get(targetKey(target))?.add(permission);
|
| 138 |
}
|
| 139 |
}
|
| 140 |
}
|
| 141 |
|
| 142 |
+
function matchesTarget(entity: JsonObject, target: StorageTarget): boolean {
|
| 143 |
+
const type = text(entity["type"]).replace(/s$/u, "");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
return (
|
| 145 |
+
type === target.kind &&
|
| 146 |
+
entityCandidates(entity).some(
|
| 147 |
+
(candidate) => normalizeName(candidate, target.kind) === target.name,
|
| 148 |
+
)
|
| 149 |
);
|
| 150 |
}
|
| 151 |
|
| 152 |
+
function targetKey(target: StorageTarget): string {
|
| 153 |
+
return `${target.kind}:${target.name}`;
|
|
|
|
| 154 |
}
|
| 155 |
|
| 156 |
function entityCandidates(entity: JsonObject): readonly string[] {
|
|
|
|
| 167 |
return `${kind}:${name}`;
|
| 168 |
}
|
| 169 |
|
| 170 |
+
function normalizeName(value: string, kind: StorageTarget["kind"]): string {
|
| 171 |
+
return value.replace(kind === "dataset" ? /^datasets\//u : /^buckets\//u, "");
|
| 172 |
}
|
| 173 |
|
| 174 |
function tokenStatusError(tokenName: "HF_TOKEN", status: number): DatasetCredentialReadiness {
|
space/src/dataset.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from "node
|
|
| 2 |
import { dirname, isAbsolute, join, normalize, relative } from "node:path";
|
| 3 |
|
| 4 |
import { commit, downloadFile, listFiles } from "@huggingface/hub";
|
|
|
|
| 5 |
|
| 6 |
import {
|
| 7 |
attemptEventSchema,
|
|
@@ -31,6 +32,13 @@ export type EnrichmentRefresh = {
|
|
| 31 |
receipt?: EnrichReceipt;
|
| 32 |
};
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
type EnrichmentShardUpdate = {
|
| 35 |
path: string;
|
| 36 |
content: string;
|
|
@@ -41,8 +49,31 @@ type EnrichmentReplayCounts = Pick<EnrichmentRefresh, "rows" | "attempts" | "reg
|
|
| 41 |
|
| 42 |
const REFRESH_SHARDS_PER_KIND = 4;
|
| 43 |
const REFRESH_ATTEMPTS = 2;
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
return (
|
| 47 |
typeof error === "object" &&
|
| 48 |
error !== null &&
|
|
@@ -54,18 +85,23 @@ function isNotFound(error: unknown): boolean {
|
|
| 54 |
function isMissingDatasetFile(error: unknown, path: string): boolean {
|
| 55 |
const message = error instanceof Error ? error.message : String(error);
|
| 56 |
return (
|
| 57 |
-
|
| 58 |
message.includes(`missing: ${path}`) ||
|
| 59 |
message.includes(`dataset file not found: ${path}`)
|
| 60 |
);
|
| 61 |
}
|
| 62 |
|
| 63 |
-
async function assertDatasetRepoReadable(
|
| 64 |
repo: { type: "dataset"; name: string },
|
| 65 |
accessToken: string,
|
|
|
|
| 66 |
): Promise<void> {
|
| 67 |
try {
|
| 68 |
-
for await (const _entry of listFiles({
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
return;
|
| 70 |
}
|
| 71 |
} catch (error) {
|
|
@@ -86,7 +122,7 @@ export function createHubClient(datasetRepo: string, accessToken: string): HubCl
|
|
| 86 |
if (entry.type === "file" && entry.path.endsWith(".jsonl")) paths.push(entry.path);
|
| 87 |
}
|
| 88 |
} catch (error) {
|
| 89 |
-
if (
|
| 90 |
// A fresh pool can lack the requested tree. Verify the repo itself is
|
| 91 |
// readable so auth failures do not look like an empty dataset.
|
| 92 |
await assertDatasetRepoReadable(repo, accessToken);
|
|
@@ -103,7 +139,7 @@ export function createHubClient(datasetRepo: string, accessToken: string): HubCl
|
|
| 103 |
await assertDatasetRepoReadable(repo, accessToken);
|
| 104 |
throw new Error(`dataset file not found: ${path}`);
|
| 105 |
} catch (error) {
|
| 106 |
-
if (
|
| 107 |
throw error;
|
| 108 |
}
|
| 109 |
},
|
|
@@ -354,6 +390,40 @@ export class DatasetMirror {
|
|
| 354 |
return count;
|
| 355 |
}
|
| 356 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
/** Read a dataset file through the Hub, returning undefined when it is absent. */
|
| 358 |
async readText(path: string): Promise<string | undefined> {
|
| 359 |
try {
|
|
@@ -381,10 +451,13 @@ export class DatasetMirror {
|
|
| 381 |
writes: readonly { path: string; content: string }[],
|
| 382 |
title: string,
|
| 383 |
): Promise<void> {
|
| 384 |
-
const
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
|
|
|
|
|
|
|
|
|
| 388 |
await this.hub.commitFiles(files, title);
|
| 389 |
for (const file of files) {
|
| 390 |
const local = this.localPath(file.path);
|
|
@@ -393,9 +466,11 @@ export class DatasetMirror {
|
|
| 393 |
}
|
| 394 |
}
|
| 395 |
|
| 396 |
-
private appendedContent(path: string, lines: readonly string[]): string {
|
| 397 |
const local = this.localPath(path);
|
| 398 |
-
const existing = existsSync(local)
|
|
|
|
|
|
|
| 399 |
const prefix = existing === "" || existing.endsWith("\n") ? existing : `${existing}\n`;
|
| 400 |
return `${prefix}${lines.map((line) => `${line}\n`).join("")}`;
|
| 401 |
}
|
|
@@ -427,6 +502,62 @@ export class DatasetMirror {
|
|
| 427 |
*/
|
| 428 |
type EnrichmentShardKind = "receipt" | "attempt" | "registry" | "row";
|
| 429 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 430 |
function classifyEnrichmentPath(path: string): EnrichmentShardKind {
|
| 431 |
if (path.startsWith("enrichment/receipts/")) return "receipt";
|
| 432 |
if (path.startsWith("enrichment/attempts/")) return "attempt";
|
|
@@ -450,6 +581,19 @@ function selectEnrichmentRefreshShards(
|
|
| 450 |
return [...selected].sort();
|
| 451 |
}
|
| 452 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 453 |
function applyEnrichmentLines(enrich: EnrichStore, content: string): number {
|
| 454 |
let rows = 0;
|
| 455 |
for (const line of content.split("\n")) {
|
|
|
|
| 2 |
import { dirname, isAbsolute, join, normalize, relative } from "node:path";
|
| 3 |
|
| 4 |
import { commit, downloadFile, listFiles } from "@huggingface/hub";
|
| 5 |
+
import { z } from "zod";
|
| 6 |
|
| 7 |
import {
|
| 8 |
attemptEventSchema,
|
|
|
|
| 32 |
receipt?: EnrichReceipt;
|
| 33 |
};
|
| 34 |
|
| 35 |
+
export type DatasetSourceKind = "tweet" | "enrichment" | "attempt" | "registry" | "receipt";
|
| 36 |
+
|
| 37 |
+
export type AppliedDatasetSource = {
|
| 38 |
+
kind: DatasetSourceKind;
|
| 39 |
+
rows: number;
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
type EnrichmentShardUpdate = {
|
| 43 |
path: string;
|
| 44 |
content: string;
|
|
|
|
| 49 |
|
| 50 |
const REFRESH_SHARDS_PER_KIND = 4;
|
| 51 |
const REFRESH_ATTEMPTS = 2;
|
| 52 |
+
const legacyReceiptSchema = z
|
| 53 |
+
.object({
|
| 54 |
+
started_at: z.string().min(1),
|
| 55 |
+
finished_at: z.string().min(1),
|
| 56 |
+
units: z.number().int().nonnegative(),
|
| 57 |
+
calls: z.number().int().nonnegative(),
|
| 58 |
+
prompt_tokens: z.number().int().nonnegative(),
|
| 59 |
+
completion_tokens: z.number().int().nonnegative(),
|
| 60 |
+
failures: z.number().int().nonnegative(),
|
| 61 |
+
})
|
| 62 |
+
.strict();
|
| 63 |
+
const legacyEnrichmentRowSchema = z
|
| 64 |
+
.object({
|
| 65 |
+
unit_id: z.string().min(1),
|
| 66 |
+
tweet_ids: z.array(z.string().min(1)).min(1),
|
| 67 |
+
labels: z.array(z.string()),
|
| 68 |
+
free_labels: z.array(z.string()),
|
| 69 |
+
concepts: z.array(z.unknown()),
|
| 70 |
+
model: z.string().min(1),
|
| 71 |
+
taxonomy_version: z.number().int().min(1),
|
| 72 |
+
enriched_at: z.string().min(1),
|
| 73 |
+
})
|
| 74 |
+
.loose();
|
| 75 |
+
|
| 76 |
+
export function isHubNotFound(error: unknown): boolean {
|
| 77 |
return (
|
| 78 |
typeof error === "object" &&
|
| 79 |
error !== null &&
|
|
|
|
| 85 |
function isMissingDatasetFile(error: unknown, path: string): boolean {
|
| 86 |
const message = error instanceof Error ? error.message : String(error);
|
| 87 |
return (
|
| 88 |
+
isHubNotFound(error) ||
|
| 89 |
message.includes(`missing: ${path}`) ||
|
| 90 |
message.includes(`dataset file not found: ${path}`)
|
| 91 |
);
|
| 92 |
}
|
| 93 |
|
| 94 |
+
export async function assertDatasetRepoReadable(
|
| 95 |
repo: { type: "dataset"; name: string },
|
| 96 |
accessToken: string,
|
| 97 |
+
revision?: string,
|
| 98 |
): Promise<void> {
|
| 99 |
try {
|
| 100 |
+
for await (const _entry of listFiles({
|
| 101 |
+
repo,
|
| 102 |
+
accessToken,
|
| 103 |
+
...(revision === undefined ? {} : { revision }),
|
| 104 |
+
})) {
|
| 105 |
return;
|
| 106 |
}
|
| 107 |
} catch (error) {
|
|
|
|
| 122 |
if (entry.type === "file" && entry.path.endsWith(".jsonl")) paths.push(entry.path);
|
| 123 |
}
|
| 124 |
} catch (error) {
|
| 125 |
+
if (isHubNotFound(error)) {
|
| 126 |
// A fresh pool can lack the requested tree. Verify the repo itself is
|
| 127 |
// readable so auth failures do not look like an empty dataset.
|
| 128 |
await assertDatasetRepoReadable(repo, accessToken);
|
|
|
|
| 139 |
await assertDatasetRepoReadable(repo, accessToken);
|
| 140 |
throw new Error(`dataset file not found: ${path}`);
|
| 141 |
} catch (error) {
|
| 142 |
+
if (isHubNotFound(error)) await assertDatasetRepoReadable(repo, accessToken);
|
| 143 |
throw error;
|
| 144 |
}
|
| 145 |
},
|
|
|
|
| 390 |
return count;
|
| 391 |
}
|
| 392 |
|
| 393 |
+
/** Apply one complete file or verified append suffix to the SQLite projection. */
|
| 394 |
+
applySourceContent(
|
| 395 |
+
path: string,
|
| 396 |
+
content: string,
|
| 397 |
+
store: TweetStore,
|
| 398 |
+
enrich: EnrichStore,
|
| 399 |
+
): AppliedDatasetSource {
|
| 400 |
+
const kind = datasetSourceKind(path);
|
| 401 |
+
switch (kind) {
|
| 402 |
+
case "tweet": {
|
| 403 |
+
const tweets = parseJsonlTweets(content, path);
|
| 404 |
+
store.insert(tweets);
|
| 405 |
+
enrich.registerTweets(tweets);
|
| 406 |
+
return { kind, rows: tweets.length };
|
| 407 |
+
}
|
| 408 |
+
case "enrichment":
|
| 409 |
+
return { kind, rows: applyEnrichmentLines(enrich, content) };
|
| 410 |
+
case "attempt":
|
| 411 |
+
return { kind, rows: replayAttemptLines(enrich, content) };
|
| 412 |
+
case "registry":
|
| 413 |
+
return { kind, rows: replayRegistryLines(enrich, content) };
|
| 414 |
+
case "receipt":
|
| 415 |
+
this.recordLatestReceipt(content);
|
| 416 |
+
return { kind, rows: countValidReceipts(content) };
|
| 417 |
+
}
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
/** Keep a current source file locally so a later append preserves its prefix. */
|
| 421 |
+
rememberSourceFile(path: string, content: string): void {
|
| 422 |
+
const local = this.localPath(path);
|
| 423 |
+
mkdirSync(dirname(local), { recursive: true });
|
| 424 |
+
writeFileSync(local, content);
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
/** Read a dataset file through the Hub, returning undefined when it is absent. */
|
| 428 |
async readText(path: string): Promise<string | undefined> {
|
| 429 |
try {
|
|
|
|
| 451 |
writes: readonly { path: string; content: string }[],
|
| 452 |
title: string,
|
| 453 |
): Promise<void> {
|
| 454 |
+
const appended = await Promise.all(
|
| 455 |
+
appends.map(async ({ path, lines }) => ({
|
| 456 |
+
path,
|
| 457 |
+
content: await this.appendedContent(path, lines),
|
| 458 |
+
})),
|
| 459 |
+
);
|
| 460 |
+
const files = [...appended, ...writes];
|
| 461 |
await this.hub.commitFiles(files, title);
|
| 462 |
for (const file of files) {
|
| 463 |
const local = this.localPath(file.path);
|
|
|
|
| 466 |
}
|
| 467 |
}
|
| 468 |
|
| 469 |
+
private async appendedContent(path: string, lines: readonly string[]): Promise<string> {
|
| 470 |
const local = this.localPath(path);
|
| 471 |
+
const existing = existsSync(local)
|
| 472 |
+
? readFileSync(local, "utf8")
|
| 473 |
+
: ((await this.readText(path)) ?? "");
|
| 474 |
const prefix = existing === "" || existing.endsWith("\n") ? existing : `${existing}\n`;
|
| 475 |
return `${prefix}${lines.map((line) => `${line}\n`).join("")}`;
|
| 476 |
}
|
|
|
|
| 502 |
*/
|
| 503 |
type EnrichmentShardKind = "receipt" | "attempt" | "registry" | "row";
|
| 504 |
|
| 505 |
+
export function assertValidDatasetSourceContent(path: string, content: string): void {
|
| 506 |
+
const kind = datasetSourceKind(path);
|
| 507 |
+
const lines = content.split("\n");
|
| 508 |
+
for (const [index, line] of lines.entries()) {
|
| 509 |
+
if (line.trim() === "") continue;
|
| 510 |
+
let candidate: unknown;
|
| 511 |
+
try {
|
| 512 |
+
candidate = JSON.parse(line);
|
| 513 |
+
} catch {
|
| 514 |
+
throw new Error(`invalid JSON in ${path} at line ${String(index + 1)}`);
|
| 515 |
+
}
|
| 516 |
+
if (!validDatasetSourceRecord(kind, candidate)) {
|
| 517 |
+
throw new Error(`invalid ${kind} record in ${path} at line ${String(index + 1)}`);
|
| 518 |
+
}
|
| 519 |
+
}
|
| 520 |
+
}
|
| 521 |
+
|
| 522 |
+
function validDatasetSourceRecord(kind: DatasetSourceKind, candidate: unknown): boolean {
|
| 523 |
+
switch (kind) {
|
| 524 |
+
case "tweet":
|
| 525 |
+
return validateTweet(candidate).ok;
|
| 526 |
+
case "enrichment":
|
| 527 |
+
return parseEnrichmentRow(candidate) !== undefined || isLegacyEnrichmentRow(candidate);
|
| 528 |
+
case "attempt":
|
| 529 |
+
return attemptEventSchema.safeParse(candidate).success;
|
| 530 |
+
case "registry":
|
| 531 |
+
return freeLabelEventSchema.safeParse(candidate).success;
|
| 532 |
+
case "receipt":
|
| 533 |
+
return (
|
| 534 |
+
parseEnrichReceipt(candidate) !== undefined ||
|
| 535 |
+
legacyReceiptSchema.safeParse(candidate).success
|
| 536 |
+
);
|
| 537 |
+
}
|
| 538 |
+
}
|
| 539 |
+
|
| 540 |
+
function isLegacyEnrichmentRow(candidate: unknown): boolean {
|
| 541 |
+
return legacyEnrichmentRowSchema.safeParse(candidate).success;
|
| 542 |
+
}
|
| 543 |
+
|
| 544 |
+
export function datasetSourceKind(path: string): DatasetSourceKind {
|
| 545 |
+
if (/^data\/[^/]+\/\d{4}\/\d{2}\/tweets-\d{4}-\d{2}-\d{2}\.jsonl$/u.test(path)) {
|
| 546 |
+
return "tweet";
|
| 547 |
+
}
|
| 548 |
+
if (/^enrichment\/\d{4}\/\d{2}\/enrichment-\d{4}-\d{2}-\d{2}\.jsonl$/u.test(path)) {
|
| 549 |
+
return "enrichment";
|
| 550 |
+
}
|
| 551 |
+
if (/^enrichment\/attempts\/\d{4}\/\d{2}\/attempts-\d{4}-\d{2}-\d{2}\.jsonl$/u.test(path)) {
|
| 552 |
+
return "attempt";
|
| 553 |
+
}
|
| 554 |
+
if (/^enrichment\/registry\/\d{4}\/\d{2}\/registry-\d{4}-\d{2}-\d{2}\.jsonl$/u.test(path)) {
|
| 555 |
+
return "registry";
|
| 556 |
+
}
|
| 557 |
+
if (/^enrichment\/receipts\/\d{4}-\d{2}-\d{2}\.jsonl$/u.test(path)) return "receipt";
|
| 558 |
+
throw new Error(`unsupported dataset index source: ${path}`);
|
| 559 |
+
}
|
| 560 |
+
|
| 561 |
function classifyEnrichmentPath(path: string): EnrichmentShardKind {
|
| 562 |
if (path.startsWith("enrichment/receipts/")) return "receipt";
|
| 563 |
if (path.startsWith("enrichment/attempts/")) return "attempt";
|
|
|
|
| 581 |
return [...selected].sort();
|
| 582 |
}
|
| 583 |
|
| 584 |
+
function countValidReceipts(content: string): number {
|
| 585 |
+
let count = 0;
|
| 586 |
+
for (const line of content.split("\n")) {
|
| 587 |
+
if (line.trim() === "") continue;
|
| 588 |
+
try {
|
| 589 |
+
if (parseEnrichReceipt(JSON.parse(line)) !== undefined) count += 1;
|
| 590 |
+
} catch {
|
| 591 |
+
continue;
|
| 592 |
+
}
|
| 593 |
+
}
|
| 594 |
+
return count;
|
| 595 |
+
}
|
| 596 |
+
|
| 597 |
function applyEnrichmentLines(enrich: EnrichStore, content: string): number {
|
| 598 |
let rows = 0;
|
| 599 |
for (const line of content.split("\n")) {
|
space/src/durable-index.ts
ADDED
|
@@ -0,0 +1,887 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createHash, randomUUID } from "node:crypto";
|
| 2 |
+
import { createReadStream, createWriteStream, existsSync, openAsBlob } from "node:fs";
|
| 3 |
+
import { mkdir, rename, rm } from "node:fs/promises";
|
| 4 |
+
import { dirname } from "node:path";
|
| 5 |
+
import { Readable } from "node:stream";
|
| 6 |
+
import { pipeline } from "node:stream/promises";
|
| 7 |
+
|
| 8 |
+
import Database from "better-sqlite3";
|
| 9 |
+
import {
|
| 10 |
+
commit,
|
| 11 |
+
datasetInfo,
|
| 12 |
+
deleteFiles,
|
| 13 |
+
downloadFile,
|
| 14 |
+
listFiles,
|
| 15 |
+
uploadFile,
|
| 16 |
+
} from "@huggingface/hub";
|
| 17 |
+
import { z } from "zod";
|
| 18 |
+
|
| 19 |
+
import {
|
| 20 |
+
assertDatasetRepoReadable,
|
| 21 |
+
assertValidDatasetSourceContent,
|
| 22 |
+
DatasetMirror,
|
| 23 |
+
datasetSourceKind,
|
| 24 |
+
isHubNotFound,
|
| 25 |
+
} from "./dataset.js";
|
| 26 |
+
import type { DatasetSourceKind } from "./dataset.js";
|
| 27 |
+
import { EnrichStore } from "./enrich-store.js";
|
| 28 |
+
import { TweetStore } from "./store.js";
|
| 29 |
+
|
| 30 |
+
const INDEX_SCHEMA_VERSION = 1;
|
| 31 |
+
const CURRENT_MANIFEST_KEY = "index/current.json";
|
| 32 |
+
const DATABASE_PREFIX = "index/databases";
|
| 33 |
+
const RETAINED_PREDECESSORS = 3;
|
| 34 |
+
const MAX_PUBLICATION_ATTEMPTS = 5;
|
| 35 |
+
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
|
| 36 |
+
const REVISION_PATTERN = /^[a-f0-9]{7,64}$/;
|
| 37 |
+
const DATABASE_KEY_PATTERN = /^index\/databases\/[a-f0-9]{64}\.sqlite$/;
|
| 38 |
+
|
| 39 |
+
const indexCountsSchema = z
|
| 40 |
+
.object({
|
| 41 |
+
tweets: z.number().int().nonnegative(),
|
| 42 |
+
units: z.number().int().nonnegative(),
|
| 43 |
+
enrichments: z.number().int().nonnegative(),
|
| 44 |
+
attempt_events: z.number().int().nonnegative(),
|
| 45 |
+
registry_events: z.number().int().nonnegative(),
|
| 46 |
+
})
|
| 47 |
+
.strict();
|
| 48 |
+
|
| 49 |
+
export const durableIndexManifestSchema = z
|
| 50 |
+
.object({
|
| 51 |
+
schema_version: z.literal(INDEX_SCHEMA_VERSION),
|
| 52 |
+
dataset: z
|
| 53 |
+
.object({ repo: z.string().min(1), revision: z.string().regex(REVISION_PATTERN) })
|
| 54 |
+
.strict(),
|
| 55 |
+
projection: z.object({ contract_hash: z.string().regex(SHA256_PATTERN) }).strict(),
|
| 56 |
+
database: z
|
| 57 |
+
.object({
|
| 58 |
+
key: z.string().regex(DATABASE_KEY_PATTERN),
|
| 59 |
+
sha256: z.string().regex(SHA256_PATTERN),
|
| 60 |
+
predecessors: z
|
| 61 |
+
.array(z.string().regex(DATABASE_KEY_PATTERN))
|
| 62 |
+
.max(RETAINED_PREDECESSORS)
|
| 63 |
+
.refine((keys) => new Set(keys).size === keys.length, "predecessors must be unique"),
|
| 64 |
+
})
|
| 65 |
+
.strict(),
|
| 66 |
+
counts: indexCountsSchema,
|
| 67 |
+
})
|
| 68 |
+
.strict()
|
| 69 |
+
.superRefine((manifest, context) => {
|
| 70 |
+
const expected = `${DATABASE_PREFIX}/${manifest.database.sha256}.sqlite`;
|
| 71 |
+
if (manifest.database.key !== expected) {
|
| 72 |
+
context.addIssue({
|
| 73 |
+
code: "custom",
|
| 74 |
+
path: ["database", "key"],
|
| 75 |
+
message: `database key must be ${expected}`,
|
| 76 |
+
});
|
| 77 |
+
}
|
| 78 |
+
if (manifest.database.predecessors.includes(manifest.database.key)) {
|
| 79 |
+
context.addIssue({
|
| 80 |
+
code: "custom",
|
| 81 |
+
path: ["database", "predecessors"],
|
| 82 |
+
message: "predecessors must not include the active database",
|
| 83 |
+
});
|
| 84 |
+
}
|
| 85 |
+
});
|
| 86 |
+
|
| 87 |
+
export type DurableIndexManifest = z.infer<typeof durableIndexManifestSchema>;
|
| 88 |
+
export type DurableIndexCounts = z.infer<typeof indexCountsSchema>;
|
| 89 |
+
|
| 90 |
+
export type DatasetSourceFile = {
|
| 91 |
+
path: string;
|
| 92 |
+
oid: string;
|
| 93 |
+
size: number;
|
| 94 |
+
};
|
| 95 |
+
|
| 96 |
+
export type DatasetSnapshotClient = {
|
| 97 |
+
currentRevision(): Promise<string>;
|
| 98 |
+
listJsonlFiles(revision: string): Promise<readonly DatasetSourceFile[]>;
|
| 99 |
+
downloadFile(path: string, revision: string): Promise<Uint8Array>;
|
| 100 |
+
readText(path: string, revision: string): Promise<string | undefined>;
|
| 101 |
+
commitText(path: string, content: string, parentRevision: string): Promise<string>;
|
| 102 |
+
};
|
| 103 |
+
|
| 104 |
+
export type BucketFile = { path: string; uploadedAt?: string };
|
| 105 |
+
|
| 106 |
+
export type DurableIndexBucketClient = {
|
| 107 |
+
download(path: string, destination: string): Promise<boolean>;
|
| 108 |
+
uploadFile(path: string, source: string): Promise<void>;
|
| 109 |
+
list(prefix: string): Promise<readonly BucketFile[]>;
|
| 110 |
+
remove(paths: readonly string[]): Promise<void>;
|
| 111 |
+
};
|
| 112 |
+
|
| 113 |
+
type SourceFileRow = {
|
| 114 |
+
path: string;
|
| 115 |
+
kind: DatasetSourceKind;
|
| 116 |
+
oid: string;
|
| 117 |
+
byte_length: number;
|
| 118 |
+
content_sha256: string;
|
| 119 |
+
row_count: number;
|
| 120 |
+
};
|
| 121 |
+
|
| 122 |
+
type IndexMetadataRow = {
|
| 123 |
+
schema_version: number;
|
| 124 |
+
dataset_repo: string;
|
| 125 |
+
dataset_revision: string;
|
| 126 |
+
contract_hash: string;
|
| 127 |
+
};
|
| 128 |
+
|
| 129 |
+
type StagedSourceFile = {
|
| 130 |
+
current: DatasetSourceFile;
|
| 131 |
+
kind: DatasetSourceKind;
|
| 132 |
+
fullContent: Uint8Array;
|
| 133 |
+
fullText: string;
|
| 134 |
+
suffixText: string;
|
| 135 |
+
contentSha256: string;
|
| 136 |
+
previousRows: number;
|
| 137 |
+
};
|
| 138 |
+
|
| 139 |
+
export type DurableIndexOptions = {
|
| 140 |
+
datasetRepo: string;
|
| 141 |
+
indexBucket: string;
|
| 142 |
+
accessToken: string;
|
| 143 |
+
databasePath: string;
|
| 144 |
+
mirror: DatasetMirror;
|
| 145 |
+
taxonomyVersion: number;
|
| 146 |
+
contractHash: string;
|
| 147 |
+
sourceClient?: DatasetSnapshotClient;
|
| 148 |
+
bucketClient?: DurableIndexBucketClient;
|
| 149 |
+
predecessorKeys?: readonly string[];
|
| 150 |
+
};
|
| 151 |
+
|
| 152 |
+
export type IndexAdvance = {
|
| 153 |
+
revision: string;
|
| 154 |
+
filesChanged: number;
|
| 155 |
+
rowsApplied: number;
|
| 156 |
+
counts: DurableIndexCounts;
|
| 157 |
+
};
|
| 158 |
+
|
| 159 |
+
export type DurableIndexStats = {
|
| 160 |
+
tweetFiles: number;
|
| 161 |
+
tweetRows: number;
|
| 162 |
+
enrichmentFiles: number;
|
| 163 |
+
enrichmentRows: number;
|
| 164 |
+
attemptEvents: number;
|
| 165 |
+
registryEvents: number;
|
| 166 |
+
};
|
| 167 |
+
|
| 168 |
+
/**
|
| 169 |
+
* Restored local SQLite projection backed by an immutable Bucket generation.
|
| 170 |
+
* The dataset stays authoritative; this class only advances strict append-only
|
| 171 |
+
* JSONL inputs and publishes verified replacement generations.
|
| 172 |
+
*/
|
| 173 |
+
export class DurableIndex {
|
| 174 |
+
readonly store: TweetStore;
|
| 175 |
+
readonly enrichStore: EnrichStore;
|
| 176 |
+
|
| 177 |
+
private constructor(
|
| 178 |
+
private readonly options: DurableIndexOptions,
|
| 179 |
+
private readonly source: DatasetSnapshotClient,
|
| 180 |
+
private readonly bucket: DurableIndexBucketClient,
|
| 181 |
+
store: TweetStore,
|
| 182 |
+
enrichStore: EnrichStore,
|
| 183 |
+
private publishedKeys: string[],
|
| 184 |
+
) {
|
| 185 |
+
this.store = store;
|
| 186 |
+
this.enrichStore = enrichStore;
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
static async restore(options: DurableIndexOptions): Promise<DurableIndex> {
|
| 190 |
+
const source =
|
| 191 |
+
options.sourceClient ?? createDatasetSnapshotClient(options.datasetRepo, options.accessToken);
|
| 192 |
+
const bucket =
|
| 193 |
+
options.bucketClient ??
|
| 194 |
+
createDurableIndexBucketClient(options.indexBucket, options.accessToken);
|
| 195 |
+
const manifestRevision = await source.currentRevision();
|
| 196 |
+
const rawManifest = await source.readText(CURRENT_MANIFEST_KEY, manifestRevision);
|
| 197 |
+
if (rawManifest === undefined) {
|
| 198 |
+
throw new Error("durable index manifest is missing; run the index bootstrap command");
|
| 199 |
+
}
|
| 200 |
+
const manifest = parseManifest(rawManifest, options);
|
| 201 |
+
await mkdir(dirname(options.databasePath), { recursive: true });
|
| 202 |
+
const staged = `${options.databasePath}.${randomUUID()}.download`;
|
| 203 |
+
try {
|
| 204 |
+
if (!(await bucket.download(manifest.database.key, staged))) {
|
| 205 |
+
throw new Error(`durable index database is missing: ${manifest.database.key}`);
|
| 206 |
+
}
|
| 207 |
+
await assertFileSha256(staged, manifest.database.sha256);
|
| 208 |
+
await removeDatabaseFiles(options.databasePath);
|
| 209 |
+
await rename(staged, options.databasePath);
|
| 210 |
+
} finally {
|
| 211 |
+
await rm(staged, { force: true });
|
| 212 |
+
}
|
| 213 |
+
const store = new TweetStore(options.databasePath);
|
| 214 |
+
const enrichStore = new EnrichStore(
|
| 215 |
+
store.database,
|
| 216 |
+
options.taxonomyVersion,
|
| 217 |
+
(): Date => new Date(),
|
| 218 |
+
options.contractHash,
|
| 219 |
+
);
|
| 220 |
+
ensureIndexTables(store.database);
|
| 221 |
+
validateDatabase(store.database, manifest, options);
|
| 222 |
+
const index = new DurableIndex(options, source, bucket, store, enrichStore, [
|
| 223 |
+
manifest.database.key,
|
| 224 |
+
...manifest.database.predecessors,
|
| 225 |
+
]);
|
| 226 |
+
await index.loadLatestReceipt(manifest.dataset.revision);
|
| 227 |
+
return index;
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
static openLocal(options: DurableIndexOptions): DurableIndex {
|
| 231 |
+
if (!existsSync(options.databasePath)) {
|
| 232 |
+
throw new Error(`durable index working database is missing: ${options.databasePath}`);
|
| 233 |
+
}
|
| 234 |
+
const source =
|
| 235 |
+
options.sourceClient ?? createDatasetSnapshotClient(options.datasetRepo, options.accessToken);
|
| 236 |
+
const bucket =
|
| 237 |
+
options.bucketClient ??
|
| 238 |
+
createDurableIndexBucketClient(options.indexBucket, options.accessToken);
|
| 239 |
+
const store = new TweetStore(options.databasePath);
|
| 240 |
+
const enrichStore = new EnrichStore(
|
| 241 |
+
store.database,
|
| 242 |
+
options.taxonomyVersion,
|
| 243 |
+
(): Date => new Date(),
|
| 244 |
+
options.contractHash,
|
| 245 |
+
);
|
| 246 |
+
ensureIndexTables(store.database);
|
| 247 |
+
assertDatabaseIntegrity(store.database);
|
| 248 |
+
const metadata = readMetadata(store.database);
|
| 249 |
+
if (
|
| 250 |
+
metadata?.schema_version !== INDEX_SCHEMA_VERSION ||
|
| 251 |
+
metadata.dataset_repo !== options.datasetRepo ||
|
| 252 |
+
metadata.contract_hash !== options.contractHash
|
| 253 |
+
) {
|
| 254 |
+
store.close();
|
| 255 |
+
throw new Error("durable index working database provenance mismatch");
|
| 256 |
+
}
|
| 257 |
+
return new DurableIndex(
|
| 258 |
+
options,
|
| 259 |
+
source,
|
| 260 |
+
bucket,
|
| 261 |
+
store,
|
| 262 |
+
enrichStore,
|
| 263 |
+
configuredPredecessors(options),
|
| 264 |
+
);
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
static async bootstrap(options: DurableIndexOptions): Promise<DurableIndex> {
|
| 268 |
+
const source =
|
| 269 |
+
options.sourceClient ?? createDatasetSnapshotClient(options.datasetRepo, options.accessToken);
|
| 270 |
+
const bucket =
|
| 271 |
+
options.bucketClient ??
|
| 272 |
+
createDurableIndexBucketClient(options.indexBucket, options.accessToken);
|
| 273 |
+
await mkdir(dirname(options.databasePath), { recursive: true });
|
| 274 |
+
await removeDatabaseFiles(options.databasePath);
|
| 275 |
+
const store = new TweetStore(options.databasePath);
|
| 276 |
+
const enrichStore = new EnrichStore(
|
| 277 |
+
store.database,
|
| 278 |
+
options.taxonomyVersion,
|
| 279 |
+
(): Date => new Date(),
|
| 280 |
+
options.contractHash,
|
| 281 |
+
);
|
| 282 |
+
ensureIndexTables(store.database);
|
| 283 |
+
const index = new DurableIndex(options, source, bucket, store, enrichStore, []);
|
| 284 |
+
await index.advanceToLatest();
|
| 285 |
+
return index;
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
async advanceToLatest(): Promise<IndexAdvance> {
|
| 289 |
+
return this.advanceToRevision(await this.source.currentRevision());
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
async advanceToRevision(revision: string): Promise<IndexAdvance> {
|
| 293 |
+
if (!REVISION_PATTERN.test(revision)) throw new Error(`invalid dataset revision: ${revision}`);
|
| 294 |
+
const currentFiles = [...(await this.source.listJsonlFiles(revision))].sort((a, b) =>
|
| 295 |
+
sourceOrder(a.path, b.path),
|
| 296 |
+
);
|
| 297 |
+
const previous = sourceFileRows(this.store.database);
|
| 298 |
+
const currentPaths = new Set(currentFiles.map((file) => file.path));
|
| 299 |
+
const deleted = [...previous.keys()].filter((path) => !currentPaths.has(path));
|
| 300 |
+
if (deleted.length > 0) {
|
| 301 |
+
throw new Error(`dataset source files were deleted: ${deleted.slice(0, 3).join(", ")}`);
|
| 302 |
+
}
|
| 303 |
+
const staged: StagedSourceFile[] = [];
|
| 304 |
+
for (const file of currentFiles) {
|
| 305 |
+
const old = previous.get(file.path);
|
| 306 |
+
if (old?.oid === file.oid && old.byte_length === file.size) continue;
|
| 307 |
+
staged.push(await this.stageSourceFile(file, old, revision));
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
let rowsApplied = 0;
|
| 311 |
+
const apply = this.store.database.transaction(() => {
|
| 312 |
+
for (const file of staged) {
|
| 313 |
+
const applied = this.options.mirror.applySourceContent(
|
| 314 |
+
file.current.path,
|
| 315 |
+
file.suffixText,
|
| 316 |
+
this.store,
|
| 317 |
+
this.enrichStore,
|
| 318 |
+
);
|
| 319 |
+
if (applied.kind !== file.kind)
|
| 320 |
+
throw new Error(`source kind changed: ${file.current.path}`);
|
| 321 |
+
rowsApplied += applied.rows;
|
| 322 |
+
upsertSourceFile(this.store.database, {
|
| 323 |
+
path: file.current.path,
|
| 324 |
+
kind: file.kind,
|
| 325 |
+
oid: file.current.oid,
|
| 326 |
+
byte_length: file.fullContent.byteLength,
|
| 327 |
+
content_sha256: file.contentSha256,
|
| 328 |
+
row_count: file.previousRows + applied.rows,
|
| 329 |
+
});
|
| 330 |
+
}
|
| 331 |
+
writeMetadata(this.store.database, {
|
| 332 |
+
schema_version: INDEX_SCHEMA_VERSION,
|
| 333 |
+
dataset_repo: this.options.datasetRepo,
|
| 334 |
+
dataset_revision: revision,
|
| 335 |
+
contract_hash: this.options.contractHash,
|
| 336 |
+
});
|
| 337 |
+
});
|
| 338 |
+
apply();
|
| 339 |
+
for (const file of staged) {
|
| 340 |
+
this.options.mirror.rememberSourceFile(file.current.path, file.fullText);
|
| 341 |
+
}
|
| 342 |
+
await this.loadLatestReceipt(revision, currentFiles);
|
| 343 |
+
const counts = databaseCounts(this.store.database);
|
| 344 |
+
assertDatabaseIntegrity(this.store.database);
|
| 345 |
+
return { revision, filesChanged: staged.length, rowsApplied, counts };
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
stats(): DurableIndexStats {
|
| 349 |
+
const rows = this.store.database
|
| 350 |
+
.prepare(
|
| 351 |
+
`SELECT kind, COUNT(*) AS files, COALESCE(SUM(row_count), 0) AS rows
|
| 352 |
+
FROM source_files GROUP BY kind`,
|
| 353 |
+
)
|
| 354 |
+
.all() as { kind: DatasetSourceKind; files: number; rows: number }[];
|
| 355 |
+
const byKind = new Map(rows.map((row) => [row.kind, row]));
|
| 356 |
+
const tweets = sourceKindStats(byKind, "tweet");
|
| 357 |
+
const enrichments = sourceKindStats(byKind, "enrichment");
|
| 358 |
+
return {
|
| 359 |
+
tweetFiles: tweets.files,
|
| 360 |
+
tweetRows: tweets.rows,
|
| 361 |
+
enrichmentFiles: enrichments.files,
|
| 362 |
+
enrichmentRows: enrichments.rows,
|
| 363 |
+
attemptEvents: sourceKindStats(byKind, "attempt").rows,
|
| 364 |
+
registryEvents: sourceKindStats(byKind, "registry").rows,
|
| 365 |
+
};
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
retainedDatabaseKeys(): readonly string[] {
|
| 369 |
+
return [...this.publishedKeys];
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
async createWorkingCopy(path: string): Promise<void> {
|
| 373 |
+
await rm(path, { force: true });
|
| 374 |
+
await this.store.database.backup(path);
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
async publishLatest(): Promise<{
|
| 378 |
+
advance: IndexAdvance;
|
| 379 |
+
manifest: DurableIndexManifest;
|
| 380 |
+
}> {
|
| 381 |
+
for (let attempt = 1; attempt <= MAX_PUBLICATION_ATTEMPTS; attempt += 1) {
|
| 382 |
+
const advance = await this.advanceToLatest();
|
| 383 |
+
try {
|
| 384 |
+
return { advance, manifest: await this.publish() };
|
| 385 |
+
} catch (error) {
|
| 386 |
+
if (attempt === MAX_PUBLICATION_ATTEMPTS || !isConcurrentDatasetUpdate(error)) throw error;
|
| 387 |
+
}
|
| 388 |
+
}
|
| 389 |
+
throw new Error("durable index publication retry invariant failed");
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
async publish(): Promise<DurableIndexManifest> {
|
| 393 |
+
const metadata = readMetadata(this.store.database);
|
| 394 |
+
if (metadata === undefined) throw new Error("durable index metadata is missing");
|
| 395 |
+
assertDatabaseIntegrity(this.store.database);
|
| 396 |
+
const counts = databaseCounts(this.store.database);
|
| 397 |
+
const publicationPath = `${this.options.databasePath}.${randomUUID()}.publish`;
|
| 398 |
+
const verificationPath = `${this.options.databasePath}.${randomUUID()}.verify`;
|
| 399 |
+
try {
|
| 400 |
+
await this.store.database.backup(publicationPath);
|
| 401 |
+
validateStandaloneDatabase(publicationPath, metadata, counts);
|
| 402 |
+
const sha256 = await fileSha256(publicationPath);
|
| 403 |
+
const key = `${DATABASE_PREFIX}/${sha256}.sqlite`;
|
| 404 |
+
await this.bucket.uploadFile(key, publicationPath);
|
| 405 |
+
if (!(await this.bucket.download(key, verificationPath))) {
|
| 406 |
+
throw new Error(`uploaded durable index database is unavailable: ${key}`);
|
| 407 |
+
}
|
| 408 |
+
await assertFileSha256(verificationPath, sha256);
|
| 409 |
+
validateStandaloneDatabase(verificationPath, metadata, counts);
|
| 410 |
+
const predecessors = this.publishedKeys
|
| 411 |
+
.filter((publishedKey) => publishedKey !== key)
|
| 412 |
+
.slice(0, RETAINED_PREDECESSORS);
|
| 413 |
+
const manifest: DurableIndexManifest = {
|
| 414 |
+
schema_version: INDEX_SCHEMA_VERSION,
|
| 415 |
+
dataset: { repo: metadata.dataset_repo, revision: metadata.dataset_revision },
|
| 416 |
+
projection: { contract_hash: metadata.contract_hash },
|
| 417 |
+
database: { key, sha256, predecessors },
|
| 418 |
+
counts,
|
| 419 |
+
};
|
| 420 |
+
const encoded = `${JSON.stringify(manifest, null, 2)}\n`;
|
| 421 |
+
const manifestRevision = await this.source.commitText(
|
| 422 |
+
CURRENT_MANIFEST_KEY,
|
| 423 |
+
encoded,
|
| 424 |
+
metadata.dataset_revision,
|
| 425 |
+
);
|
| 426 |
+
const active = await this.source.readText(CURRENT_MANIFEST_KEY, manifestRevision);
|
| 427 |
+
if (
|
| 428 |
+
active === undefined ||
|
| 429 |
+
JSON.stringify(parseManifest(active, this.options)) !== JSON.stringify(manifest)
|
| 430 |
+
) {
|
| 431 |
+
throw new Error("durable index manifest read-back did not match the published generation");
|
| 432 |
+
}
|
| 433 |
+
this.publishedKeys = [key, ...predecessors];
|
| 434 |
+
await this.pruneDatabases(this.publishedKeys);
|
| 435 |
+
return manifest;
|
| 436 |
+
} finally {
|
| 437 |
+
await Promise.all([
|
| 438 |
+
rm(publicationPath, { force: true }),
|
| 439 |
+
rm(verificationPath, { force: true }),
|
| 440 |
+
]);
|
| 441 |
+
}
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
close(): void {
|
| 445 |
+
this.store.close();
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
private async stageSourceFile(
|
| 449 |
+
current: DatasetSourceFile,
|
| 450 |
+
previous: SourceFileRow | undefined,
|
| 451 |
+
revision: string,
|
| 452 |
+
): Promise<StagedSourceFile> {
|
| 453 |
+
const kind = datasetSourceKind(current.path);
|
| 454 |
+
assertSourceKind(current.path, kind, previous);
|
| 455 |
+
const content = await this.source.downloadFile(current.path, revision);
|
| 456 |
+
assertCompletePinnedSource(current, content);
|
| 457 |
+
const suffix = sourceSuffix(current.path, content, previous);
|
| 458 |
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
| 459 |
+
const fullText = decoder.decode(content);
|
| 460 |
+
const suffixText = decoder.decode(suffix);
|
| 461 |
+
assertValidDatasetSourceContent(current.path, suffixText);
|
| 462 |
+
return {
|
| 463 |
+
current,
|
| 464 |
+
kind,
|
| 465 |
+
fullContent: content,
|
| 466 |
+
fullText,
|
| 467 |
+
suffixText,
|
| 468 |
+
contentSha256: sha256Bytes(content),
|
| 469 |
+
previousRows: previous?.row_count ?? 0,
|
| 470 |
+
};
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
private async loadLatestReceipt(
|
| 474 |
+
revision: string,
|
| 475 |
+
knownFiles?: readonly DatasetSourceFile[],
|
| 476 |
+
): Promise<void> {
|
| 477 |
+
const files = knownFiles ?? (await this.source.listJsonlFiles(revision));
|
| 478 |
+
const latest = files
|
| 479 |
+
.filter((file) => datasetSourceKind(file.path) === "receipt")
|
| 480 |
+
.sort((a, b) => a.path.localeCompare(b.path))
|
| 481 |
+
.at(-1);
|
| 482 |
+
if (latest === undefined) return;
|
| 483 |
+
const content = await this.source.downloadFile(latest.path, revision);
|
| 484 |
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(content);
|
| 485 |
+
this.options.mirror.applySourceContent(latest.path, text, this.store, this.enrichStore);
|
| 486 |
+
this.options.mirror.rememberSourceFile(latest.path, text);
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
+
private async pruneDatabases(retainedKeys: readonly string[]): Promise<void> {
|
| 490 |
+
const files = [...(await this.bucket.list(DATABASE_PREFIX))].filter((file) =>
|
| 491 |
+
file.path.endsWith(".sqlite"),
|
| 492 |
+
);
|
| 493 |
+
const keep = new Set(retainedKeys);
|
| 494 |
+
const stale = files.map((file) => file.path).filter((path) => !keep.has(path));
|
| 495 |
+
if (stale.length > 0) await this.bucket.remove(stale);
|
| 496 |
+
}
|
| 497 |
+
}
|
| 498 |
+
|
| 499 |
+
function configuredPredecessors(options: DurableIndexOptions): string[] {
|
| 500 |
+
return Array.from(options.predecessorKeys ?? []);
|
| 501 |
+
}
|
| 502 |
+
|
| 503 |
+
async function removeDatabaseFiles(path: string): Promise<void> {
|
| 504 |
+
await Promise.all([
|
| 505 |
+
rm(path, { force: true }),
|
| 506 |
+
rm(`${path}-wal`, { force: true }),
|
| 507 |
+
rm(`${path}-shm`, { force: true }),
|
| 508 |
+
]);
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
function isConcurrentDatasetUpdate(error: unknown): boolean {
|
| 512 |
+
const statusCode =
|
| 513 |
+
typeof error === "object" && error !== null && "statusCode" in error
|
| 514 |
+
? (error as { statusCode?: unknown }).statusCode
|
| 515 |
+
: undefined;
|
| 516 |
+
const message = error instanceof Error ? error.message.toLowerCase() : "";
|
| 517 |
+
return (
|
| 518 |
+
statusCode === 409 ||
|
| 519 |
+
message.includes("branch was updated") ||
|
| 520 |
+
message.includes("parent commit does not match")
|
| 521 |
+
);
|
| 522 |
+
}
|
| 523 |
+
|
| 524 |
+
function assertSourceKind(
|
| 525 |
+
path: string,
|
| 526 |
+
kind: DatasetSourceKind,
|
| 527 |
+
previous: SourceFileRow | undefined,
|
| 528 |
+
): void {
|
| 529 |
+
if (previous !== undefined && previous.kind !== kind) {
|
| 530 |
+
throw new Error(`dataset source kind changed: ${path}`);
|
| 531 |
+
}
|
| 532 |
+
}
|
| 533 |
+
|
| 534 |
+
function assertCompletePinnedSource(current: DatasetSourceFile, content: Uint8Array): void {
|
| 535 |
+
if (content.byteLength !== current.size) {
|
| 536 |
+
throw new Error(`dataset source size changed while pinned: ${current.path}`);
|
| 537 |
+
}
|
| 538 |
+
if (content.byteLength > 0 && content.at(-1) !== 0x0a) {
|
| 539 |
+
throw new Error(`dataset source does not end with a complete JSONL line: ${current.path}`);
|
| 540 |
+
}
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
function sourceSuffix(
|
| 544 |
+
path: string,
|
| 545 |
+
content: Uint8Array,
|
| 546 |
+
previous: SourceFileRow | undefined,
|
| 547 |
+
): Uint8Array {
|
| 548 |
+
if (previous === undefined) return content;
|
| 549 |
+
if (content.byteLength < previous.byte_length) {
|
| 550 |
+
throw new Error(`dataset source was truncated: ${path}`);
|
| 551 |
+
}
|
| 552 |
+
const prefix = content.subarray(0, previous.byte_length);
|
| 553 |
+
if (sha256Bytes(prefix) !== previous.content_sha256) {
|
| 554 |
+
throw new Error(`dataset source prefix changed: ${path}`);
|
| 555 |
+
}
|
| 556 |
+
return content.subarray(previous.byte_length);
|
| 557 |
+
}
|
| 558 |
+
|
| 559 |
+
export function createDatasetSnapshotClient(
|
| 560 |
+
datasetRepo: string,
|
| 561 |
+
accessToken: string,
|
| 562 |
+
): DatasetSnapshotClient {
|
| 563 |
+
const repo = { type: "dataset", name: datasetRepo } as const;
|
| 564 |
+
return {
|
| 565 |
+
async currentRevision(): Promise<string> {
|
| 566 |
+
const info = await datasetInfo({
|
| 567 |
+
name: datasetRepo,
|
| 568 |
+
accessToken,
|
| 569 |
+
additionalFields: ["sha"],
|
| 570 |
+
});
|
| 571 |
+
if (typeof info.sha !== "string" || !REVISION_PATTERN.test(info.sha)) {
|
| 572 |
+
throw new Error(`dataset ${datasetRepo} did not return a valid revision`);
|
| 573 |
+
}
|
| 574 |
+
return info.sha;
|
| 575 |
+
},
|
| 576 |
+
async listJsonlFiles(revision: string): Promise<readonly DatasetSourceFile[]> {
|
| 577 |
+
const groups = await Promise.all(
|
| 578 |
+
["data", "enrichment"].map((prefix) =>
|
| 579 |
+
listDatasetPrefix(repo, accessToken, revision, prefix),
|
| 580 |
+
),
|
| 581 |
+
);
|
| 582 |
+
return groups.flat();
|
| 583 |
+
},
|
| 584 |
+
async downloadFile(path: string, revision: string): Promise<Uint8Array> {
|
| 585 |
+
const blob = await downloadFile({ repo, accessToken, path, revision });
|
| 586 |
+
if (blob === null) throw new Error(`dataset source is missing: ${path}`);
|
| 587 |
+
return new Uint8Array(await blob.arrayBuffer());
|
| 588 |
+
},
|
| 589 |
+
async readText(path: string, revision: string): Promise<string | undefined> {
|
| 590 |
+
const blob = await downloadFile({ repo, accessToken, path, revision });
|
| 591 |
+
return blob === null ? undefined : blob.text();
|
| 592 |
+
},
|
| 593 |
+
async commitText(path: string, content: string, parentRevision: string): Promise<string> {
|
| 594 |
+
const result = await commit({
|
| 595 |
+
repo,
|
| 596 |
+
accessToken,
|
| 597 |
+
parentCommit: parentRevision,
|
| 598 |
+
title: "Publish durable enrichment index manifest",
|
| 599 |
+
operations: [{ operation: "addOrUpdate", path, content: new Blob([content]) }],
|
| 600 |
+
});
|
| 601 |
+
if (result === undefined || !REVISION_PATTERN.test(result.commit.oid)) {
|
| 602 |
+
throw new Error("dataset did not confirm the durable index manifest commit");
|
| 603 |
+
}
|
| 604 |
+
return result.commit.oid;
|
| 605 |
+
},
|
| 606 |
+
};
|
| 607 |
+
}
|
| 608 |
+
|
| 609 |
+
// eslint-disable-next-line complexity -- Hub entries require type, path, and immutable-object validation.
|
| 610 |
+
async function listDatasetPrefix(
|
| 611 |
+
repo: { type: "dataset"; name: string },
|
| 612 |
+
accessToken: string,
|
| 613 |
+
revision: string,
|
| 614 |
+
prefix: string,
|
| 615 |
+
): Promise<DatasetSourceFile[]> {
|
| 616 |
+
const files: DatasetSourceFile[] = [];
|
| 617 |
+
try {
|
| 618 |
+
for await (const entry of listFiles({
|
| 619 |
+
repo,
|
| 620 |
+
accessToken,
|
| 621 |
+
recursive: true,
|
| 622 |
+
path: prefix,
|
| 623 |
+
revision,
|
| 624 |
+
})) {
|
| 625 |
+
if (entry.type !== "file" || !entry.path.endsWith(".jsonl")) continue;
|
| 626 |
+
const oid = entry.xetHash ?? entry.lfs?.oid ?? entry.oid;
|
| 627 |
+
if (oid === undefined || oid.length === 0) {
|
| 628 |
+
throw new Error(`dataset source has no immutable object id: ${entry.path}`);
|
| 629 |
+
}
|
| 630 |
+
files.push({ path: entry.path, oid, size: entry.size });
|
| 631 |
+
}
|
| 632 |
+
} catch (error) {
|
| 633 |
+
if (!isHubNotFound(error)) throw error;
|
| 634 |
+
await assertDatasetRepoReadable(repo, accessToken, revision);
|
| 635 |
+
}
|
| 636 |
+
return files;
|
| 637 |
+
}
|
| 638 |
+
|
| 639 |
+
export function createDurableIndexBucketClient(
|
| 640 |
+
indexBucket: string,
|
| 641 |
+
accessToken: string,
|
| 642 |
+
): DurableIndexBucketClient {
|
| 643 |
+
const repo = { type: "bucket", name: indexBucket } as const;
|
| 644 |
+
return {
|
| 645 |
+
async download(path: string, destination: string): Promise<boolean> {
|
| 646 |
+
const blob = await downloadFile({ repo, accessToken, path });
|
| 647 |
+
if (blob === null) return false;
|
| 648 |
+
await mkdir(dirname(destination), { recursive: true });
|
| 649 |
+
await pipeline(Readable.fromWeb(blob.stream()), createWriteStream(destination));
|
| 650 |
+
return true;
|
| 651 |
+
},
|
| 652 |
+
async uploadFile(path: string, source: string): Promise<void> {
|
| 653 |
+
await uploadFile({
|
| 654 |
+
repo,
|
| 655 |
+
accessToken,
|
| 656 |
+
file: { path, content: await openAsBlob(source) },
|
| 657 |
+
commitTitle: `Publish ${path}`,
|
| 658 |
+
});
|
| 659 |
+
},
|
| 660 |
+
async list(prefix: string): Promise<readonly BucketFile[]> {
|
| 661 |
+
const files: BucketFile[] = [];
|
| 662 |
+
for await (const entry of listFiles({
|
| 663 |
+
repo,
|
| 664 |
+
accessToken,
|
| 665 |
+
recursive: true,
|
| 666 |
+
path: prefix,
|
| 667 |
+
expand: true,
|
| 668 |
+
})) {
|
| 669 |
+
if (entry.type === "file") {
|
| 670 |
+
files.push({
|
| 671 |
+
path: entry.path,
|
| 672 |
+
...(entry.uploadedAt === undefined ? {} : { uploadedAt: entry.uploadedAt }),
|
| 673 |
+
});
|
| 674 |
+
}
|
| 675 |
+
}
|
| 676 |
+
return files;
|
| 677 |
+
},
|
| 678 |
+
async remove(paths: readonly string[]): Promise<void> {
|
| 679 |
+
if (paths.length === 0) return;
|
| 680 |
+
await deleteFiles({
|
| 681 |
+
repo,
|
| 682 |
+
accessToken,
|
| 683 |
+
paths: [...paths],
|
| 684 |
+
commitTitle: "Prune durable index generations",
|
| 685 |
+
});
|
| 686 |
+
},
|
| 687 |
+
};
|
| 688 |
+
}
|
| 689 |
+
|
| 690 |
+
function parseManifest(
|
| 691 |
+
raw: string,
|
| 692 |
+
options: Pick<DurableIndexOptions, "datasetRepo" | "contractHash">,
|
| 693 |
+
): DurableIndexManifest {
|
| 694 |
+
let candidate: unknown;
|
| 695 |
+
try {
|
| 696 |
+
candidate = JSON.parse(raw);
|
| 697 |
+
} catch {
|
| 698 |
+
throw new Error("durable index manifest is not valid JSON");
|
| 699 |
+
}
|
| 700 |
+
const manifest = durableIndexManifestSchema.parse(candidate);
|
| 701 |
+
if (manifest.dataset.repo !== options.datasetRepo) {
|
| 702 |
+
throw new Error(`durable index dataset mismatch: ${manifest.dataset.repo}`);
|
| 703 |
+
}
|
| 704 |
+
if (manifest.projection.contract_hash !== options.contractHash) {
|
| 705 |
+
throw new Error("durable index enrichment contract does not match the running code");
|
| 706 |
+
}
|
| 707 |
+
return manifest;
|
| 708 |
+
}
|
| 709 |
+
|
| 710 |
+
function ensureIndexTables(db: Database.Database): void {
|
| 711 |
+
db.exec(`
|
| 712 |
+
CREATE TABLE IF NOT EXISTS index_metadata (
|
| 713 |
+
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
| 714 |
+
schema_version INTEGER NOT NULL,
|
| 715 |
+
dataset_repo TEXT NOT NULL,
|
| 716 |
+
dataset_revision TEXT NOT NULL,
|
| 717 |
+
contract_hash TEXT NOT NULL
|
| 718 |
+
);
|
| 719 |
+
CREATE TABLE IF NOT EXISTS source_files (
|
| 720 |
+
path TEXT PRIMARY KEY,
|
| 721 |
+
kind TEXT NOT NULL,
|
| 722 |
+
oid TEXT NOT NULL,
|
| 723 |
+
byte_length INTEGER NOT NULL CHECK (byte_length >= 0),
|
| 724 |
+
content_sha256 TEXT NOT NULL,
|
| 725 |
+
row_count INTEGER NOT NULL CHECK (row_count >= 0)
|
| 726 |
+
);
|
| 727 |
+
`);
|
| 728 |
+
}
|
| 729 |
+
|
| 730 |
+
function sourceFileRows(db: Database.Database): Map<string, SourceFileRow> {
|
| 731 |
+
const rows = db
|
| 732 |
+
.prepare(
|
| 733 |
+
`SELECT path, kind, oid, byte_length, content_sha256, row_count
|
| 734 |
+
FROM source_files ORDER BY path`,
|
| 735 |
+
)
|
| 736 |
+
.all() as SourceFileRow[];
|
| 737 |
+
return new Map(rows.map((row) => [row.path, row]));
|
| 738 |
+
}
|
| 739 |
+
|
| 740 |
+
function upsertSourceFile(db: Database.Database, row: SourceFileRow): void {
|
| 741 |
+
db.prepare(
|
| 742 |
+
`INSERT INTO source_files
|
| 743 |
+
(path, kind, oid, byte_length, content_sha256, row_count)
|
| 744 |
+
VALUES (?, ?, ?, ?, ?, ?)
|
| 745 |
+
ON CONFLICT(path) DO UPDATE SET
|
| 746 |
+
kind = excluded.kind,
|
| 747 |
+
oid = excluded.oid,
|
| 748 |
+
byte_length = excluded.byte_length,
|
| 749 |
+
content_sha256 = excluded.content_sha256,
|
| 750 |
+
row_count = excluded.row_count`,
|
| 751 |
+
).run(row.path, row.kind, row.oid, row.byte_length, row.content_sha256, row.row_count);
|
| 752 |
+
}
|
| 753 |
+
|
| 754 |
+
function writeMetadata(db: Database.Database, metadata: IndexMetadataRow): void {
|
| 755 |
+
db.prepare(
|
| 756 |
+
`INSERT INTO index_metadata
|
| 757 |
+
(singleton, schema_version, dataset_repo, dataset_revision, contract_hash)
|
| 758 |
+
VALUES (1, ?, ?, ?, ?)
|
| 759 |
+
ON CONFLICT(singleton) DO UPDATE SET
|
| 760 |
+
schema_version = excluded.schema_version,
|
| 761 |
+
dataset_repo = excluded.dataset_repo,
|
| 762 |
+
dataset_revision = excluded.dataset_revision,
|
| 763 |
+
contract_hash = excluded.contract_hash`,
|
| 764 |
+
).run(
|
| 765 |
+
metadata.schema_version,
|
| 766 |
+
metadata.dataset_repo,
|
| 767 |
+
metadata.dataset_revision,
|
| 768 |
+
metadata.contract_hash,
|
| 769 |
+
);
|
| 770 |
+
}
|
| 771 |
+
|
| 772 |
+
function readMetadata(db: Database.Database): IndexMetadataRow | undefined {
|
| 773 |
+
return db
|
| 774 |
+
.prepare(
|
| 775 |
+
`SELECT schema_version, dataset_repo, dataset_revision, contract_hash
|
| 776 |
+
FROM index_metadata WHERE singleton = 1`,
|
| 777 |
+
)
|
| 778 |
+
.get() as IndexMetadataRow | undefined;
|
| 779 |
+
}
|
| 780 |
+
|
| 781 |
+
function sourceKindStats(
|
| 782 |
+
byKind: ReadonlyMap<DatasetSourceKind, { files: number; rows: number }>,
|
| 783 |
+
kind: DatasetSourceKind,
|
| 784 |
+
): { files: number; rows: number } {
|
| 785 |
+
return byKind.get(kind) ?? { files: 0, rows: 0 };
|
| 786 |
+
}
|
| 787 |
+
|
| 788 |
+
function databaseCounts(db: Database.Database): DurableIndexCounts {
|
| 789 |
+
const count = (table: string): number =>
|
| 790 |
+
(db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as { n: number }).n;
|
| 791 |
+
const eventCount = (kind: DatasetSourceKind): number =>
|
| 792 |
+
(
|
| 793 |
+
db
|
| 794 |
+
.prepare("SELECT COALESCE(SUM(row_count), 0) AS n FROM source_files WHERE kind = ?")
|
| 795 |
+
.get(kind) as {
|
| 796 |
+
n: number;
|
| 797 |
+
}
|
| 798 |
+
).n;
|
| 799 |
+
return {
|
| 800 |
+
tweets: count("tweets"),
|
| 801 |
+
units: count("enrich_queue"),
|
| 802 |
+
enrichments: count("enrichment"),
|
| 803 |
+
attempt_events: eventCount("attempt"),
|
| 804 |
+
registry_events: eventCount("registry"),
|
| 805 |
+
};
|
| 806 |
+
}
|
| 807 |
+
|
| 808 |
+
function validateDatabase(
|
| 809 |
+
db: Database.Database,
|
| 810 |
+
manifest: DurableIndexManifest,
|
| 811 |
+
options: Pick<DurableIndexOptions, "datasetRepo" | "contractHash">,
|
| 812 |
+
): void {
|
| 813 |
+
assertDatabaseIntegrity(db);
|
| 814 |
+
const metadata = readMetadata(db);
|
| 815 |
+
if (metadata === undefined) throw new Error("durable index metadata is missing");
|
| 816 |
+
if (metadata.schema_version !== INDEX_SCHEMA_VERSION)
|
| 817 |
+
throw new Error("durable index schema version mismatch");
|
| 818 |
+
if (
|
| 819 |
+
metadata.dataset_repo !== options.datasetRepo ||
|
| 820 |
+
metadata.dataset_revision !== manifest.dataset.revision
|
| 821 |
+
) {
|
| 822 |
+
throw new Error("durable index dataset provenance mismatch");
|
| 823 |
+
}
|
| 824 |
+
if (metadata.contract_hash !== options.contractHash)
|
| 825 |
+
throw new Error("durable index contract mismatch");
|
| 826 |
+
if (JSON.stringify(databaseCounts(db)) !== JSON.stringify(manifest.counts)) {
|
| 827 |
+
throw new Error("durable index physical counts do not match the manifest");
|
| 828 |
+
}
|
| 829 |
+
}
|
| 830 |
+
|
| 831 |
+
function validateStandaloneDatabase(
|
| 832 |
+
path: string,
|
| 833 |
+
metadata: IndexMetadataRow,
|
| 834 |
+
counts: DurableIndexCounts,
|
| 835 |
+
): void {
|
| 836 |
+
const db = new Database(path, { readonly: true, fileMustExist: true });
|
| 837 |
+
try {
|
| 838 |
+
db.pragma("query_only = ON");
|
| 839 |
+
assertDatabaseIntegrity(db);
|
| 840 |
+
if (JSON.stringify(readMetadata(db)) !== JSON.stringify(metadata)) {
|
| 841 |
+
throw new Error("published durable index metadata does not match");
|
| 842 |
+
}
|
| 843 |
+
if (JSON.stringify(databaseCounts(db)) !== JSON.stringify(counts)) {
|
| 844 |
+
throw new Error("published durable index counts do not match");
|
| 845 |
+
}
|
| 846 |
+
} finally {
|
| 847 |
+
db.close();
|
| 848 |
+
}
|
| 849 |
+
}
|
| 850 |
+
|
| 851 |
+
function assertDatabaseIntegrity(db: Database.Database): void {
|
| 852 |
+
const rows = db.pragma("quick_check") as { quick_check: string }[];
|
| 853 |
+
if (rows.length !== 1 || rows[0]?.quick_check !== "ok") {
|
| 854 |
+
throw new Error("durable index SQLite quick_check failed");
|
| 855 |
+
}
|
| 856 |
+
}
|
| 857 |
+
|
| 858 |
+
function sourceOrder(left: string, right: string): number {
|
| 859 |
+
const priorities: Record<DatasetSourceKind, number> = {
|
| 860 |
+
tweet: 0,
|
| 861 |
+
enrichment: 1,
|
| 862 |
+
registry: 2,
|
| 863 |
+
attempt: 3,
|
| 864 |
+
receipt: 4,
|
| 865 |
+
};
|
| 866 |
+
const difference = priorities[datasetSourceKind(left)] - priorities[datasetSourceKind(right)];
|
| 867 |
+
return difference === 0 ? left.localeCompare(right) : difference;
|
| 868 |
+
}
|
| 869 |
+
|
| 870 |
+
async function fileSha256(path: string): Promise<string> {
|
| 871 |
+
const hash = createHash("sha256");
|
| 872 |
+
for await (const chunk of createReadStream(path)) {
|
| 873 |
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array);
|
| 874 |
+
hash.update(bytes);
|
| 875 |
+
}
|
| 876 |
+
return hash.digest("hex");
|
| 877 |
+
}
|
| 878 |
+
|
| 879 |
+
async function assertFileSha256(path: string, expected: string): Promise<void> {
|
| 880 |
+
if ((await fileSha256(path)) !== expected) {
|
| 881 |
+
throw new Error("durable index database checksum mismatch");
|
| 882 |
+
}
|
| 883 |
+
}
|
| 884 |
+
|
| 885 |
+
function sha256Bytes(content: Uint8Array): string {
|
| 886 |
+
return createHash("sha256").update(content).digest("hex");
|
| 887 |
+
}
|
space/src/enrich-command.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
|
|
| 1 |
import { join } from "node:path";
|
| 2 |
|
| 3 |
import { loadConfig } from "./config.js";
|
| 4 |
import { createHubClient, DatasetMirror } from "./dataset.js";
|
| 5 |
import { loadEnrichTaxonomy } from "./enrich-config.js";
|
| 6 |
-
import {
|
| 7 |
import {
|
| 8 |
contractHashFor,
|
| 9 |
createExactHubVerifier,
|
|
@@ -14,7 +15,6 @@ import {
|
|
| 14 |
runEnrichTick,
|
| 15 |
} from "./enrich-worker.js";
|
| 16 |
import type { WorkerCeilings } from "./enrich-worker.js";
|
| 17 |
-
import { TweetStore } from "./store.js";
|
| 18 |
|
| 19 |
/**
|
| 20 |
* Standalone worker: rebuilds the local mirror from the dataset (system of
|
|
@@ -35,10 +35,8 @@ export async function runEnrichCommand(env: Record<string, string | undefined>):
|
|
| 35 |
process.exitCode = 2;
|
| 36 |
return;
|
| 37 |
}
|
| 38 |
-
const store = new TweetStore();
|
| 39 |
const hub = createHubClient(config.datasetRepo, config.hfToken);
|
| 40 |
const mirror = new DatasetMirror(hub, join(config.dataDir, "mirror"));
|
| 41 |
-
const enrichStore = new EnrichStore(store.database, config.taxonomyVersion, () => new Date());
|
| 42 |
const taxonomy = await loadEnrichTaxonomy(mirror, config.taxonomyVersion);
|
| 43 |
if (taxonomy.error !== undefined) {
|
| 44 |
console.error(`[xtap-pool worker] taxonomy unavailable: ${taxonomy.error}`);
|
|
@@ -46,19 +44,30 @@ export async function runEnrichCommand(env: Record<string, string | undefined>):
|
|
| 46 |
return;
|
| 47 |
}
|
| 48 |
const contractHash = contractHashFor({ taxonomy, model: config.llmModel });
|
| 49 |
-
enrichStore.setContractHash(contractHash);
|
| 50 |
|
| 51 |
console.log(
|
| 52 |
-
`[xtap-pool worker]
|
| 53 |
);
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
console.log(
|
| 59 |
-
`[xtap-pool worker]
|
| 60 |
-
`${String(
|
|
|
|
| 61 |
);
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
const llm = createRouterLlmClient({
|
| 64 |
hfToken: config.inferenceToken,
|
|
@@ -82,7 +91,7 @@ export async function runEnrichCommand(env: Record<string, string | undefined>):
|
|
| 82 |
discardedAssignmentRateMinUnits: config.enrichDiscardedAssignmentRateMinUnits,
|
| 83 |
};
|
| 84 |
const receipt = await runEnrichTick({
|
| 85 |
-
enrichStore,
|
| 86 |
mirror,
|
| 87 |
taxonomy,
|
| 88 |
llm,
|
|
@@ -95,6 +104,25 @@ export async function runEnrichCommand(env: Record<string, string | undefined>):
|
|
| 95 |
now: (): Date => new Date(),
|
| 96 |
ceilings,
|
| 97 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
console.log(
|
| 99 |
`[xtap-pool worker] finished: units=${String(receipt.units)} retries=${String(receipt.retries)} ` +
|
| 100 |
`blocked=${String(receipt.blocked)} calls=${String(receipt.calls)} ` +
|
|
@@ -103,7 +131,6 @@ export async function runEnrichCommand(env: Record<string, string | undefined>):
|
|
| 103 |
`tokens=${String(receipt.prompt_tokens + receipt.completion_tokens)} ` +
|
| 104 |
`stopped_by=${receipt.stopped_by ?? "batch-complete"}`,
|
| 105 |
);
|
| 106 |
-
store.close();
|
| 107 |
}
|
| 108 |
|
| 109 |
/**
|
|
|
|
| 1 |
+
import { rm } from "node:fs/promises";
|
| 2 |
import { join } from "node:path";
|
| 3 |
|
| 4 |
import { loadConfig } from "./config.js";
|
| 5 |
import { createHubClient, DatasetMirror } from "./dataset.js";
|
| 6 |
import { loadEnrichTaxonomy } from "./enrich-config.js";
|
| 7 |
+
import { DurableIndex } from "./durable-index.js";
|
| 8 |
import {
|
| 9 |
contractHashFor,
|
| 10 |
createExactHubVerifier,
|
|
|
|
| 15 |
runEnrichTick,
|
| 16 |
} from "./enrich-worker.js";
|
| 17 |
import type { WorkerCeilings } from "./enrich-worker.js";
|
|
|
|
| 18 |
|
| 19 |
/**
|
| 20 |
* Standalone worker: rebuilds the local mirror from the dataset (system of
|
|
|
|
| 35 |
process.exitCode = 2;
|
| 36 |
return;
|
| 37 |
}
|
|
|
|
| 38 |
const hub = createHubClient(config.datasetRepo, config.hfToken);
|
| 39 |
const mirror = new DatasetMirror(hub, join(config.dataDir, "mirror"));
|
|
|
|
| 40 |
const taxonomy = await loadEnrichTaxonomy(mirror, config.taxonomyVersion);
|
| 41 |
if (taxonomy.error !== undefined) {
|
| 42 |
console.error(`[xtap-pool worker] taxonomy unavailable: ${taxonomy.error}`);
|
|
|
|
| 44 |
return;
|
| 45 |
}
|
| 46 |
const contractHash = contractHashFor({ taxonomy, model: config.llmModel });
|
|
|
|
| 47 |
|
| 48 |
console.log(
|
| 49 |
+
`[xtap-pool worker] restoring durable index from ${config.indexBucket}, contract ${contractHash.slice(0, 12)} ...`,
|
| 50 |
);
|
| 51 |
+
const indexOptions = {
|
| 52 |
+
datasetRepo: config.datasetRepo,
|
| 53 |
+
indexBucket: config.indexBucket,
|
| 54 |
+
accessToken: config.hfToken,
|
| 55 |
+
databasePath: join(config.dataDir, "index", "worker.sqlite"),
|
| 56 |
+
mirror,
|
| 57 |
+
taxonomyVersion: config.taxonomyVersion,
|
| 58 |
+
contractHash,
|
| 59 |
+
};
|
| 60 |
+
const index = await DurableIndex.restore(indexOptions);
|
| 61 |
+
const advanced = await index.advanceToLatest();
|
| 62 |
+
index.enrichStore.releaseClaims();
|
| 63 |
console.log(
|
| 64 |
+
`[xtap-pool worker] index revision ${advanced.revision.slice(0, 12)}; ` +
|
| 65 |
+
`changed_files=${String(advanced.filesChanged)} rows=${String(advanced.rowsApplied)} ` +
|
| 66 |
+
`tweets=${String(advanced.counts.tweets)} units=${String(advanced.counts.units)}`,
|
| 67 |
);
|
| 68 |
+
const publicationBase = join(config.dataDir, "index", "publication-base.sqlite");
|
| 69 |
+
await index.createWorkingCopy(publicationBase);
|
| 70 |
+
const predecessorKeys = index.retainedDatabaseKeys();
|
| 71 |
|
| 72 |
const llm = createRouterLlmClient({
|
| 73 |
hfToken: config.inferenceToken,
|
|
|
|
| 91 |
discardedAssignmentRateMinUnits: config.enrichDiscardedAssignmentRateMinUnits,
|
| 92 |
};
|
| 93 |
const receipt = await runEnrichTick({
|
| 94 |
+
enrichStore: index.enrichStore,
|
| 95 |
mirror,
|
| 96 |
taxonomy,
|
| 97 |
llm,
|
|
|
|
| 104 |
now: (): Date => new Date(),
|
| 105 |
ceilings,
|
| 106 |
});
|
| 107 |
+
index.close();
|
| 108 |
+
const publicationMirror = new DatasetMirror(hub, join(config.dataDir, "publication-mirror"));
|
| 109 |
+
const publication = DurableIndex.openLocal({
|
| 110 |
+
...indexOptions,
|
| 111 |
+
databasePath: publicationBase,
|
| 112 |
+
mirror: publicationMirror,
|
| 113 |
+
predecessorKeys,
|
| 114 |
+
});
|
| 115 |
+
try {
|
| 116 |
+
const { advance: finalAdvance, manifest } = await publication.publishLatest();
|
| 117 |
+
console.log(
|
| 118 |
+
`[xtap-pool worker] published index ${manifest.database.sha256.slice(0, 12)} at ` +
|
| 119 |
+
`${finalAdvance.revision.slice(0, 12)}; changed_files=${String(finalAdvance.filesChanged)} ` +
|
| 120 |
+
`rows=${String(finalAdvance.rowsApplied)}`,
|
| 121 |
+
);
|
| 122 |
+
} finally {
|
| 123 |
+
publication.close();
|
| 124 |
+
await rm(publicationBase, { force: true });
|
| 125 |
+
}
|
| 126 |
console.log(
|
| 127 |
`[xtap-pool worker] finished: units=${String(receipt.units)} retries=${String(receipt.retries)} ` +
|
| 128 |
`blocked=${String(receipt.blocked)} calls=${String(receipt.calls)} ` +
|
|
|
|
| 131 |
`tokens=${String(receipt.prompt_tokens + receipt.completion_tokens)} ` +
|
| 132 |
`stopped_by=${receipt.stopped_by ?? "batch-complete"}`,
|
| 133 |
);
|
|
|
|
| 134 |
}
|
| 135 |
|
| 136 |
/**
|
space/src/index-command-main.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { runIndexCommand } from "./index-command.js";
|
| 2 |
+
|
| 3 |
+
try {
|
| 4 |
+
await runIndexCommand(process.env);
|
| 5 |
+
} catch (error) {
|
| 6 |
+
const message = error instanceof Error ? error.message : "unknown error";
|
| 7 |
+
console.error(`[xtap-pool index] fatal: ${message}`);
|
| 8 |
+
process.exitCode = 1;
|
| 9 |
+
}
|
space/src/index-command.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { join } from "node:path";
|
| 2 |
+
|
| 3 |
+
import { DEFAULT_ENRICHMENT_MODEL } from "@xtap-pool/shared";
|
| 4 |
+
import { z } from "zod";
|
| 5 |
+
|
| 6 |
+
import { createHubClient, DatasetMirror } from "./dataset.js";
|
| 7 |
+
import { DurableIndex } from "./durable-index.js";
|
| 8 |
+
import { loadEnrichTaxonomy } from "./enrich-config.js";
|
| 9 |
+
import { contractHashFor } from "./enrich-worker.js";
|
| 10 |
+
|
| 11 |
+
const indexCommandConfigSchema = z.object({
|
| 12 |
+
DATA_DIR: z.string().default(".data"),
|
| 13 |
+
DATASET_REPO: z.string().min(1),
|
| 14 |
+
INDEX_BUCKET: z.string().min(1),
|
| 15 |
+
HF_TOKEN: z.string().min(1),
|
| 16 |
+
LLM_MODEL: z.string().min(1).default(DEFAULT_ENRICHMENT_MODEL),
|
| 17 |
+
TAXONOMY_VERSION: z.coerce.number().int().min(1).default(1),
|
| 18 |
+
});
|
| 19 |
+
|
| 20 |
+
/** Explicit full replay used to seed or repair the durable enrichment index. */
|
| 21 |
+
export async function runIndexCommand(env: Record<string, string | undefined>): Promise<void> {
|
| 22 |
+
const config = indexCommandConfigSchema.parse(env);
|
| 23 |
+
const hub = createHubClient(config.DATASET_REPO, config.HF_TOKEN);
|
| 24 |
+
const mirror = new DatasetMirror(hub, join(config.DATA_DIR, "index-bootstrap-mirror"));
|
| 25 |
+
const taxonomy = await loadEnrichTaxonomy(mirror, config.TAXONOMY_VERSION);
|
| 26 |
+
if (taxonomy.error !== undefined) {
|
| 27 |
+
throw new Error(`enrichment taxonomy unavailable: ${taxonomy.error}`);
|
| 28 |
+
}
|
| 29 |
+
const contractHash = contractHashFor({ taxonomy, model: config.LLM_MODEL });
|
| 30 |
+
const index = await DurableIndex.bootstrap({
|
| 31 |
+
datasetRepo: config.DATASET_REPO,
|
| 32 |
+
indexBucket: config.INDEX_BUCKET,
|
| 33 |
+
accessToken: config.HF_TOKEN,
|
| 34 |
+
databasePath: join(config.DATA_DIR, "index", "bootstrap.sqlite"),
|
| 35 |
+
mirror,
|
| 36 |
+
taxonomyVersion: config.TAXONOMY_VERSION,
|
| 37 |
+
contractHash,
|
| 38 |
+
});
|
| 39 |
+
try {
|
| 40 |
+
const { manifest } = await index.publishLatest();
|
| 41 |
+
const stats = index.stats();
|
| 42 |
+
console.log(
|
| 43 |
+
`[xtap-pool index] published ${manifest.database.sha256} at ` +
|
| 44 |
+
`${manifest.dataset.revision}; tweets=${String(stats.tweetRows)} ` +
|
| 45 |
+
`enrichments=${String(stats.enrichmentRows)} attempts=${String(stats.attemptEvents)}`,
|
| 46 |
+
);
|
| 47 |
+
} finally {
|
| 48 |
+
index.close();
|
| 49 |
+
}
|
| 50 |
+
}
|
space/src/server.ts
CHANGED
|
@@ -7,25 +7,20 @@ import { createApp } from "./app.js";
|
|
| 7 |
import type { AppReadiness } from "./app.js";
|
| 8 |
import { loadConfig } from "./config.js";
|
| 9 |
import { createHubClient, DatasetMirror } from "./dataset.js";
|
| 10 |
-
import { datasetStateFromRebuildError, errorStatus } from "./dataset-state.js";
|
| 11 |
import type { DatasetState } from "./dataset-state.js";
|
| 12 |
import { checkDatasetCredential, datasetCredentialOk } from "./dataset-token.js";
|
| 13 |
import type { DatasetCredentialReadiness } from "./dataset-token.js";
|
| 14 |
import { loadEnrichTaxonomy } from "./enrich-config.js";
|
| 15 |
-
import {
|
| 16 |
import { contractHashFor } from "./enrich-worker.js";
|
| 17 |
import { ingestBatch, Mutex } from "./ingest.js";
|
| 18 |
import { checkInferenceCredential, inferenceCredentialOk } from "./inference-token.js";
|
| 19 |
import type { InferenceCredentialReadiness } from "./inference-token.js";
|
| 20 |
import { PoolMembership } from "./membership.js";
|
| 21 |
import { ServiceAccountRegistry } from "./service-accounts.js";
|
| 22 |
-
import { TweetStore } from "./store.js";
|
| 23 |
import { UnitStore } from "./unit-store.js";
|
| 24 |
|
| 25 |
const config = loadConfig(process.env);
|
| 26 |
-
const store = new TweetStore();
|
| 27 |
-
const enrichStore = new EnrichStore(store.database, config.taxonomyVersion);
|
| 28 |
-
const unitStore = new UnitStore(store.database, config.taxonomyVersion);
|
| 29 |
const hub = createHubClient(config.datasetRepo, config.hfToken);
|
| 30 |
const mirror = new DatasetMirror(hub, join(config.dataDir, "mirror"));
|
| 31 |
const mutex = new Mutex();
|
|
@@ -37,7 +32,7 @@ let rebuilt: RebuildStats = { files: 0, tweets: 0 };
|
|
| 37 |
let enrichment: EnrichmentStats = { files: 0, rows: 0 };
|
| 38 |
let datasetState: DatasetState = {
|
| 39 |
state: "unknown",
|
| 40 |
-
error: "The dataset index has not been
|
| 41 |
};
|
| 42 |
let datasetCredential: DatasetCredentialReadiness = {
|
| 43 |
credential: "unknown",
|
|
@@ -49,14 +44,40 @@ let inferenceCredential: InferenceCredentialReadiness = config.enrichEnabled
|
|
| 49 |
let readiness: AppReadiness;
|
| 50 |
let credentialRetryTimer: ReturnType<typeof setTimeout> | undefined;
|
| 51 |
let enrichmentRefreshTimer: ReturnType<typeof setTimeout> | undefined;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
[datasetCredential, inferenceCredential] = await Promise.all([
|
| 54 |
-
checkDatasetCredential({
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
checkInferenceCredential({
|
| 56 |
enabled: config.enrichEnabled,
|
| 57 |
token: config.inferenceToken,
|
| 58 |
}),
|
| 59 |
]);
|
|
|
|
| 60 |
|
| 61 |
const [membership, serviceAccounts] = await Promise.all([
|
| 62 |
PoolMembership.load({
|
|
@@ -68,8 +89,23 @@ const [membership, serviceAccounts] = await Promise.all([
|
|
| 68 |
ServiceAccountRegistry.load({ mirror, now: () => new Date() }),
|
| 69 |
]);
|
| 70 |
let taxonomy = await loadEnrichTaxonomy(mirror, config.taxonomyVersion);
|
| 71 |
-
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
let lastReceipt: import("@xtap-pool/shared").EnrichReceipt | undefined;
|
| 74 |
function recordLastReceipt(receipt: import("@xtap-pool/shared").EnrichReceipt | undefined): void {
|
| 75 |
if (receipt?.contract_hash !== enrichStore.currentContractHash()) return;
|
|
@@ -77,6 +113,10 @@ function recordLastReceipt(receipt: import("@xtap-pool/shared").EnrichReceipt |
|
|
| 77 |
lastReceipt = receipt;
|
| 78 |
}
|
| 79 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
const app = createApp({
|
| 81 |
config,
|
| 82 |
store,
|
|
@@ -91,9 +131,25 @@ const app = createApp({
|
|
| 91 |
lastReceipt: () => lastReceipt,
|
| 92 |
},
|
| 93 |
ingest: (username, payload) =>
|
| 94 |
-
mutex.run(() =>
|
| 95 |
-
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
repairMembership: (actor) =>
|
| 98 |
mutex.run(async () => {
|
| 99 |
const pool = await membership.repairConfig(actor);
|
|
@@ -113,8 +169,11 @@ const app = createApp({
|
|
| 113 |
app.use("*", serveStatic({ root: config.staticRoot }));
|
| 114 |
app.use("*", serveStatic({ root: config.staticRoot, path: "index.html" }));
|
| 115 |
|
| 116 |
-
console.log(
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
| 118 |
readiness = buildReadiness();
|
| 119 |
const pool = membership.snapshot();
|
| 120 |
console.log(
|
|
@@ -135,37 +194,15 @@ if (config.enrichEnabled) {
|
|
| 135 |
"to drain the queue.",
|
| 136 |
);
|
| 137 |
}
|
|
|
|
| 138 |
startCredentialRetryIfNeeded();
|
| 139 |
startEnrichmentRefresh();
|
| 140 |
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
}
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
if (!datasetCredentialOk(datasetCredential)) return;
|
| 147 |
-
datasetState = { state: "unknown", error: "Rebuilding the dataset index." };
|
| 148 |
-
readiness = buildReadiness();
|
| 149 |
-
mirror.clearForRebuild();
|
| 150 |
-
enrichStore.clearForRebuild();
|
| 151 |
-
store.clearForRebuild();
|
| 152 |
-
rebuilt = { files: 0, tweets: 0 };
|
| 153 |
-
enrichment = { files: 0, rows: 0 };
|
| 154 |
-
try {
|
| 155 |
-
rebuilt = await mirror.rebuild(store, enrichStore);
|
| 156 |
-
enrichment = await mirror.rebuildEnrichment(enrichStore);
|
| 157 |
-
recordLastReceipt(mirror.latestReceipt());
|
| 158 |
-
enrichStore.releaseClaims();
|
| 159 |
-
datasetState = { state: "ready" };
|
| 160 |
-
} catch (error) {
|
| 161 |
-
const status = errorStatus(error);
|
| 162 |
-
if (status === 401 || status === 403) {
|
| 163 |
-
datasetCredential = { credential: "invalid", error: errorMessage(error) };
|
| 164 |
-
datasetState = { state: "unknown", error: "Dataset indexing requires a valid HF_TOKEN." };
|
| 165 |
-
} else {
|
| 166 |
-
datasetState = datasetStateFromRebuildError(error);
|
| 167 |
-
}
|
| 168 |
-
}
|
| 169 |
}
|
| 170 |
|
| 171 |
/**
|
|
@@ -179,30 +216,31 @@ function startEnrichmentRefresh(): void {
|
|
| 179 |
enrichmentRefreshTimer = undefined;
|
| 180 |
void refreshExternalEnrichment()
|
| 181 |
.catch((error: unknown) => {
|
| 182 |
-
|
|
|
|
|
|
|
|
|
|
| 183 |
})
|
| 184 |
.finally(startEnrichmentRefresh);
|
| 185 |
}, enrichmentRefreshMs());
|
| 186 |
}
|
| 187 |
|
| 188 |
async function refreshExternalEnrichment(): Promise<void> {
|
| 189 |
-
if (!datasetCredentialOk(datasetCredential)
|
| 190 |
await mutex.run(async () => {
|
| 191 |
-
|
| 192 |
-
if (!datasetCredentialOk(datasetCredential) || datasetState.state !== "ready") return;
|
| 193 |
const nextTaxonomy = await loadEnrichTaxonomy(mirror, config.taxonomyVersion);
|
| 194 |
if (nextTaxonomy.error !== undefined) {
|
| 195 |
throw new Error(`enrichment taxonomy refresh failed: ${nextTaxonomy.error}`);
|
| 196 |
}
|
| 197 |
-
const
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
enrichment = { ...enrichment, rows: enrichStore.enrichmentRowCount() };
|
| 206 |
readiness = buildReadiness();
|
| 207 |
});
|
| 208 |
}
|
|
@@ -296,12 +334,17 @@ function taxonomyReady(): boolean {
|
|
| 296 |
return taxonomy.error === undefined;
|
| 297 |
}
|
| 298 |
|
|
|
|
| 299 |
async function reloadDatasetBackedConfig(force: boolean): Promise<void> {
|
| 300 |
if (force || membership.hasRetryableConfigError()) await membership.reload();
|
| 301 |
if (force || serviceAccounts.hasRetryableConfigError()) await serviceAccounts.reload();
|
| 302 |
if (config.enrichEnabled && (force || !taxonomyReady())) {
|
| 303 |
-
|
| 304 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
}
|
| 306 |
}
|
| 307 |
|
|
@@ -341,6 +384,7 @@ async function retryUncertainCredentials(): Promise<void> {
|
|
| 341 |
datasetCredential = await checkDatasetCredential({
|
| 342 |
token: config.hfToken,
|
| 343 |
datasetRepo: config.datasetRepo,
|
|
|
|
| 344 |
});
|
| 345 |
}
|
| 346 |
const datasetRecovered = !datasetWasReady && datasetCredentialOk(datasetCredential);
|
|
@@ -348,7 +392,9 @@ async function retryUncertainCredentials(): Promise<void> {
|
|
| 348 |
await mutex.run(async () => {
|
| 349 |
await reloadDatasetBackedConfig(datasetRecovered);
|
| 350 |
if (datasetRecovered || datasetState.state === "unknown") {
|
| 351 |
-
await
|
|
|
|
|
|
|
| 352 |
}
|
| 353 |
});
|
| 354 |
}
|
|
@@ -361,6 +407,22 @@ async function retryUncertainCredentials(): Promise<void> {
|
|
| 361 |
readiness = buildReadiness();
|
| 362 |
}
|
| 363 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 364 |
function credentialRetryMs(): number {
|
| 365 |
return Math.min(Math.max(config.enrichIntervalMs, 5000), 60000);
|
| 366 |
}
|
|
|
|
| 7 |
import type { AppReadiness } from "./app.js";
|
| 8 |
import { loadConfig } from "./config.js";
|
| 9 |
import { createHubClient, DatasetMirror } from "./dataset.js";
|
|
|
|
| 10 |
import type { DatasetState } from "./dataset-state.js";
|
| 11 |
import { checkDatasetCredential, datasetCredentialOk } from "./dataset-token.js";
|
| 12 |
import type { DatasetCredentialReadiness } from "./dataset-token.js";
|
| 13 |
import { loadEnrichTaxonomy } from "./enrich-config.js";
|
| 14 |
+
import { DurableIndex } from "./durable-index.js";
|
| 15 |
import { contractHashFor } from "./enrich-worker.js";
|
| 16 |
import { ingestBatch, Mutex } from "./ingest.js";
|
| 17 |
import { checkInferenceCredential, inferenceCredentialOk } from "./inference-token.js";
|
| 18 |
import type { InferenceCredentialReadiness } from "./inference-token.js";
|
| 19 |
import { PoolMembership } from "./membership.js";
|
| 20 |
import { ServiceAccountRegistry } from "./service-accounts.js";
|
|
|
|
| 21 |
import { UnitStore } from "./unit-store.js";
|
| 22 |
|
| 23 |
const config = loadConfig(process.env);
|
|
|
|
|
|
|
|
|
|
| 24 |
const hub = createHubClient(config.datasetRepo, config.hfToken);
|
| 25 |
const mirror = new DatasetMirror(hub, join(config.dataDir, "mirror"));
|
| 26 |
const mutex = new Mutex();
|
|
|
|
| 32 |
let enrichment: EnrichmentStats = { files: 0, rows: 0 };
|
| 33 |
let datasetState: DatasetState = {
|
| 34 |
state: "unknown",
|
| 35 |
+
error: "The durable dataset index has not been restored yet.",
|
| 36 |
};
|
| 37 |
let datasetCredential: DatasetCredentialReadiness = {
|
| 38 |
credential: "unknown",
|
|
|
|
| 44 |
let readiness: AppReadiness;
|
| 45 |
let credentialRetryTimer: ReturnType<typeof setTimeout> | undefined;
|
| 46 |
let enrichmentRefreshTimer: ReturnType<typeof setTimeout> | undefined;
|
| 47 |
+
let activeFetch: (request: Request) => Response | Promise<Response> = () =>
|
| 48 |
+
Response.json(
|
| 49 |
+
{
|
| 50 |
+
ok: false,
|
| 51 |
+
dataset: {
|
| 52 |
+
credential: datasetCredential.credential,
|
| 53 |
+
state: datasetCredential.credential === "invalid" ? "invalid" : "unknown",
|
| 54 |
+
indexed_files: 0,
|
| 55 |
+
indexed_tweets: 0,
|
| 56 |
+
enrichment_rows: 0,
|
| 57 |
+
...(datasetCredential.credential === "ok"
|
| 58 |
+
? {}
|
| 59 |
+
: { credential_error: datasetCredential.error }),
|
| 60 |
+
error: datasetState.state === "ready" ? undefined : datasetState.error,
|
| 61 |
+
},
|
| 62 |
+
},
|
| 63 |
+
{ status: 503 },
|
| 64 |
+
);
|
| 65 |
+
serve({ fetch: (request: Request) => activeFetch(request), port: config.port }, (info) => {
|
| 66 |
+
console.log(`[xtap-pool] listening on :${String(info.port)}`);
|
| 67 |
+
});
|
| 68 |
|
| 69 |
[datasetCredential, inferenceCredential] = await Promise.all([
|
| 70 |
+
checkDatasetCredential({
|
| 71 |
+
token: config.hfToken,
|
| 72 |
+
datasetRepo: config.datasetRepo,
|
| 73 |
+
indexBucket: config.indexBucket,
|
| 74 |
+
}),
|
| 75 |
checkInferenceCredential({
|
| 76 |
enabled: config.enrichEnabled,
|
| 77 |
token: config.inferenceToken,
|
| 78 |
}),
|
| 79 |
]);
|
| 80 |
+
await waitForStorageCredential();
|
| 81 |
|
| 82 |
const [membership, serviceAccounts] = await Promise.all([
|
| 83 |
PoolMembership.load({
|
|
|
|
| 89 |
ServiceAccountRegistry.load({ mirror, now: () => new Date() }),
|
| 90 |
]);
|
| 91 |
let taxonomy = await loadEnrichTaxonomy(mirror, config.taxonomyVersion);
|
| 92 |
+
if (taxonomy.error !== undefined) {
|
| 93 |
+
throw new Error(`enrichment taxonomy unavailable: ${taxonomy.error}`);
|
| 94 |
+
}
|
| 95 |
+
const contractHash = contractHashFor({ taxonomy, model: config.llmModel });
|
| 96 |
+
const index = await DurableIndex.restore({
|
| 97 |
+
datasetRepo: config.datasetRepo,
|
| 98 |
+
indexBucket: config.indexBucket,
|
| 99 |
+
accessToken: config.hfToken,
|
| 100 |
+
databasePath: join(config.dataDir, "index", "space.sqlite"),
|
| 101 |
+
mirror,
|
| 102 |
+
taxonomyVersion: config.taxonomyVersion,
|
| 103 |
+
contractHash,
|
| 104 |
+
});
|
| 105 |
+
const initialAdvance = await index.advanceToLatest();
|
| 106 |
+
const store = index.store;
|
| 107 |
+
const enrichStore = index.enrichStore;
|
| 108 |
+
const unitStore = new UnitStore(store.database, config.taxonomyVersion);
|
| 109 |
let lastReceipt: import("@xtap-pool/shared").EnrichReceipt | undefined;
|
| 110 |
function recordLastReceipt(receipt: import("@xtap-pool/shared").EnrichReceipt | undefined): void {
|
| 111 |
if (receipt?.contract_hash !== enrichStore.currentContractHash()) return;
|
|
|
|
| 113 |
lastReceipt = receipt;
|
| 114 |
}
|
| 115 |
}
|
| 116 |
+
applyIndexStats();
|
| 117 |
+
datasetState = { state: "ready" };
|
| 118 |
+
enrichStore.releaseClaims();
|
| 119 |
+
readiness = buildReadiness();
|
| 120 |
const app = createApp({
|
| 121 |
config,
|
| 122 |
store,
|
|
|
|
| 131 |
lastReceipt: () => lastReceipt,
|
| 132 |
},
|
| 133 |
ingest: (username, payload) =>
|
| 134 |
+
mutex.run(async () => {
|
| 135 |
+
const result = await ingestBatch(
|
| 136 |
+
{ store, mirror, enrich: enrichStore, now: () => new Date() },
|
| 137 |
+
username,
|
| 138 |
+
payload,
|
| 139 |
+
);
|
| 140 |
+
try {
|
| 141 |
+
await index.advanceToLatest();
|
| 142 |
+
applyIndexStats();
|
| 143 |
+
datasetState = { state: "ready" };
|
| 144 |
+
readiness = buildReadiness();
|
| 145 |
+
} catch (error) {
|
| 146 |
+
const message = errorMessage(error);
|
| 147 |
+
datasetState = { state: "invalid", error: `durable index ingest sync failed: ${message}` };
|
| 148 |
+
readiness = buildReadiness();
|
| 149 |
+
throw error;
|
| 150 |
+
}
|
| 151 |
+
return result;
|
| 152 |
+
}),
|
| 153 |
repairMembership: (actor) =>
|
| 154 |
mutex.run(async () => {
|
| 155 |
const pool = await membership.repairConfig(actor);
|
|
|
|
| 169 |
app.use("*", serveStatic({ root: config.staticRoot }));
|
| 170 |
app.use("*", serveStatic({ root: config.staticRoot, path: "index.html" }));
|
| 171 |
|
| 172 |
+
console.log(
|
| 173 |
+
`[xtap-pool] restored durable index ${initialAdvance.revision.slice(0, 12)} from ` +
|
| 174 |
+
`${config.indexBucket}; changed_files=${String(initialAdvance.filesChanged)} ` +
|
| 175 |
+
`rows=${String(initialAdvance.rowsApplied)}`,
|
| 176 |
+
);
|
| 177 |
readiness = buildReadiness();
|
| 178 |
const pool = membership.snapshot();
|
| 179 |
console.log(
|
|
|
|
| 194 |
"to drain the queue.",
|
| 195 |
);
|
| 196 |
}
|
| 197 |
+
activeFetch = (request) => app.fetch(request);
|
| 198 |
startCredentialRetryIfNeeded();
|
| 199 |
startEnrichmentRefresh();
|
| 200 |
|
| 201 |
+
function applyIndexStats(): void {
|
| 202 |
+
const stats = index.stats();
|
| 203 |
+
rebuilt = { files: stats.tweetFiles, tweets: stats.tweetRows };
|
| 204 |
+
enrichment = { files: stats.enrichmentFiles, rows: stats.enrichmentRows };
|
| 205 |
+
recordLastReceipt(mirror.latestReceipt());
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
}
|
| 207 |
|
| 208 |
/**
|
|
|
|
| 216 |
enrichmentRefreshTimer = undefined;
|
| 217 |
void refreshExternalEnrichment()
|
| 218 |
.catch((error: unknown) => {
|
| 219 |
+
const message = errorMessage(error);
|
| 220 |
+
datasetState = { state: "invalid", error: `durable index refresh failed: ${message}` };
|
| 221 |
+
readiness = buildReadiness();
|
| 222 |
+
console.error(`[xtap-pool] enrichment refresh failed: ${message}`);
|
| 223 |
})
|
| 224 |
.finally(startEnrichmentRefresh);
|
| 225 |
}, enrichmentRefreshMs());
|
| 226 |
}
|
| 227 |
|
| 228 |
async function refreshExternalEnrichment(): Promise<void> {
|
| 229 |
+
if (!datasetCredentialOk(datasetCredential)) return;
|
| 230 |
await mutex.run(async () => {
|
| 231 |
+
if (!datasetCredentialOk(datasetCredential)) return;
|
|
|
|
| 232 |
const nextTaxonomy = await loadEnrichTaxonomy(mirror, config.taxonomyVersion);
|
| 233 |
if (nextTaxonomy.error !== undefined) {
|
| 234 |
throw new Error(`enrichment taxonomy refresh failed: ${nextTaxonomy.error}`);
|
| 235 |
}
|
| 236 |
+
const nextContractHash = contractHashFor({ taxonomy: nextTaxonomy, model: config.llmModel });
|
| 237 |
+
if (nextContractHash !== enrichStore.currentContractHash()) {
|
| 238 |
+
throw new Error("enrichment contract changed; publish a replacement durable index");
|
| 239 |
+
}
|
| 240 |
+
taxonomy = nextTaxonomy;
|
| 241 |
+
await index.advanceToLatest();
|
| 242 |
+
applyIndexStats();
|
| 243 |
+
datasetState = { state: "ready" };
|
|
|
|
| 244 |
readiness = buildReadiness();
|
| 245 |
});
|
| 246 |
}
|
|
|
|
| 334 |
return taxonomy.error === undefined;
|
| 335 |
}
|
| 336 |
|
| 337 |
+
// eslint-disable-next-line complexity -- Recovery checks each independently persisted dataset-backed configuration.
|
| 338 |
async function reloadDatasetBackedConfig(force: boolean): Promise<void> {
|
| 339 |
if (force || membership.hasRetryableConfigError()) await membership.reload();
|
| 340 |
if (force || serviceAccounts.hasRetryableConfigError()) await serviceAccounts.reload();
|
| 341 |
if (config.enrichEnabled && (force || !taxonomyReady())) {
|
| 342 |
+
const nextTaxonomy = await loadEnrichTaxonomy(mirror, config.taxonomyVersion);
|
| 343 |
+
const nextContractHash = contractHashFor({ taxonomy: nextTaxonomy, model: config.llmModel });
|
| 344 |
+
if (nextContractHash !== enrichStore.currentContractHash()) {
|
| 345 |
+
throw new Error("enrichment contract changed; publish a replacement durable index");
|
| 346 |
+
}
|
| 347 |
+
taxonomy = nextTaxonomy;
|
| 348 |
}
|
| 349 |
}
|
| 350 |
|
|
|
|
| 384 |
datasetCredential = await checkDatasetCredential({
|
| 385 |
token: config.hfToken,
|
| 386 |
datasetRepo: config.datasetRepo,
|
| 387 |
+
indexBucket: config.indexBucket,
|
| 388 |
});
|
| 389 |
}
|
| 390 |
const datasetRecovered = !datasetWasReady && datasetCredentialOk(datasetCredential);
|
|
|
|
| 392 |
await mutex.run(async () => {
|
| 393 |
await reloadDatasetBackedConfig(datasetRecovered);
|
| 394 |
if (datasetRecovered || datasetState.state === "unknown") {
|
| 395 |
+
await index.advanceToLatest();
|
| 396 |
+
applyIndexStats();
|
| 397 |
+
datasetState = { state: "ready" };
|
| 398 |
}
|
| 399 |
});
|
| 400 |
}
|
|
|
|
| 407 |
readiness = buildReadiness();
|
| 408 |
}
|
| 409 |
|
| 410 |
+
async function waitForStorageCredential(): Promise<void> {
|
| 411 |
+
while (!datasetCredentialOk(datasetCredential)) {
|
| 412 |
+
datasetState = {
|
| 413 |
+
state: datasetCredential.credential === "invalid" ? "invalid" : "unknown",
|
| 414 |
+
error: "error" in datasetCredential ? datasetCredential.error : "HF_TOKEN is unavailable.",
|
| 415 |
+
};
|
| 416 |
+
console.error(`[xtap-pool] storage credential unavailable: ${datasetState.error}`);
|
| 417 |
+
await new Promise((resolve) => setTimeout(resolve, credentialRetryMs()));
|
| 418 |
+
datasetCredential = await checkDatasetCredential({
|
| 419 |
+
token: config.hfToken,
|
| 420 |
+
datasetRepo: config.datasetRepo,
|
| 421 |
+
indexBucket: config.indexBucket,
|
| 422 |
+
});
|
| 423 |
+
}
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
function credentialRetryMs(): number {
|
| 427 |
return Math.min(Math.max(config.enrichIntervalMs, 5000), 60000);
|
| 428 |
}
|
space/tests/config.test.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { loadConfig } from "../src/config.js";
|
|
| 4 |
|
| 5 |
const baseEnv = {
|
| 6 |
DATASET_REPO: "osolmaz/xtap-pool-data",
|
|
|
|
| 7 |
HF_TOKEN: "hf_x",
|
| 8 |
POOL_SIGNING_SECRET: "pool-secret-0123456789abcdef0123456789abcdef",
|
| 9 |
SESSION_SECRET: "session-secret-0123456789abcdef0123456789ab",
|
|
@@ -18,6 +19,7 @@ describe("loadConfig", () => {
|
|
| 18 |
it("parses a full environment with defaults", () => {
|
| 19 |
const config = loadConfig(baseEnv);
|
| 20 |
expect(config.port).toBe(7860);
|
|
|
|
| 21 |
expect(config.allowedUsers).toEqual(["osolmaz", "alice", "bob"]);
|
| 22 |
expect(config.poolAdmins).toEqual(["osolmaz"]);
|
| 23 |
expect(config.publicUrl).toBe("https://dutifuldev-xtap-pool.hf.space");
|
|
@@ -52,7 +54,7 @@ describe("loadConfig", () => {
|
|
| 52 |
expect(config.enrichEnabled).toBe(false);
|
| 53 |
expect(config.enrichIntervalMs).toBe(60000);
|
| 54 |
expect(config.enrichMaxConcurrentCalls).toBe(1);
|
| 55 |
-
expect(config.llmModel).toBe("zai-org/GLM-5.2");
|
| 56 |
expect(config.taxonomyVersion).toBe(1);
|
| 57 |
});
|
| 58 |
|
|
|
|
| 4 |
|
| 5 |
const baseEnv = {
|
| 6 |
DATASET_REPO: "osolmaz/xtap-pool-data",
|
| 7 |
+
INDEX_BUCKET: "osolmaz/xtap-pool-bucket",
|
| 8 |
HF_TOKEN: "hf_x",
|
| 9 |
POOL_SIGNING_SECRET: "pool-secret-0123456789abcdef0123456789abcdef",
|
| 10 |
SESSION_SECRET: "session-secret-0123456789abcdef0123456789ab",
|
|
|
|
| 19 |
it("parses a full environment with defaults", () => {
|
| 20 |
const config = loadConfig(baseEnv);
|
| 21 |
expect(config.port).toBe(7860);
|
| 22 |
+
expect(config.indexBucket).toBe("osolmaz/xtap-pool-bucket");
|
| 23 |
expect(config.allowedUsers).toEqual(["osolmaz", "alice", "bob"]);
|
| 24 |
expect(config.poolAdmins).toEqual(["osolmaz"]);
|
| 25 |
expect(config.publicUrl).toBe("https://dutifuldev-xtap-pool.hf.space");
|
|
|
|
| 54 |
expect(config.enrichEnabled).toBe(false);
|
| 55 |
expect(config.enrichIntervalMs).toBe(60000);
|
| 56 |
expect(config.enrichMaxConcurrentCalls).toBe(1);
|
| 57 |
+
expect(config.llmModel).toBe("zai-org/GLM-5.2:fireworks-ai");
|
| 58 |
expect(config.taxonomyVersion).toBe(1);
|
| 59 |
});
|
| 60 |
|
space/tests/dataset-token.test.ts
CHANGED
|
@@ -2,103 +2,92 @@ import { describe, expect, it, vi } from "vitest";
|
|
| 2 |
|
| 3 |
import { checkDatasetCredential } from "../src/dataset-token.js";
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
const fetchFn: typeof fetch = () =>
|
| 8 |
Promise.resolve(
|
| 9 |
-
Response.json(
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
role: "fineGrained",
|
| 13 |
-
fineGrained: {
|
| 14 |
-
global: [],
|
| 15 |
-
scoped: [
|
| 16 |
-
{
|
| 17 |
-
entity: { type: "dataset", name: "alice/xtap-pool-data" },
|
| 18 |
-
permissions: ["repo.content.read", "repo.content.write"],
|
| 19 |
-
},
|
| 20 |
-
],
|
| 21 |
-
},
|
| 22 |
-
},
|
| 23 |
-
},
|
| 24 |
-
}),
|
| 25 |
);
|
| 26 |
|
| 27 |
-
await expect(
|
| 28 |
-
|
| 29 |
-
|
|
|
|
| 30 |
});
|
| 31 |
|
| 32 |
-
it("rejects tokens whose metadata passes but
|
| 33 |
const fetchFn = vi
|
| 34 |
.fn<typeof fetch>()
|
| 35 |
-
.mockResolvedValueOnce(
|
| 36 |
-
|
| 37 |
-
auth: {
|
| 38 |
-
accessToken: {
|
| 39 |
-
role: "fineGrained",
|
| 40 |
-
fineGrained: {
|
| 41 |
-
global: [],
|
| 42 |
-
scoped: [
|
| 43 |
-
{
|
| 44 |
-
entity: { type: "dataset", name: "alice/xtap-pool-data" },
|
| 45 |
-
permissions: ["repo.content.read", "repo.content.write"],
|
| 46 |
-
},
|
| 47 |
-
],
|
| 48 |
-
},
|
| 49 |
-
},
|
| 50 |
-
},
|
| 51 |
-
}),
|
| 52 |
-
)
|
| 53 |
.mockResolvedValueOnce(new Response("unauthorized", { status: 401 }));
|
| 54 |
|
| 55 |
-
await expect(
|
| 56 |
-
checkDatasetCredential({ token: "hf_dataset", datasetRepo: "alice/xtap-pool-data", fetchFn }),
|
| 57 |
-
).resolves.toEqual({
|
| 58 |
credential: "invalid",
|
| 59 |
-
error: "Hugging Face rejected a direct private-
|
| 60 |
});
|
| 61 |
});
|
| 62 |
|
| 63 |
-
it("rejects
|
| 64 |
const fetchFn: typeof fetch = () =>
|
| 65 |
Promise.resolve(
|
| 66 |
-
Response.json(
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
scoped: [
|
| 73 |
-
{
|
| 74 |
-
entity: { type: "dataset", name: "alice/xtap-pool-data" },
|
| 75 |
-
permissions: ["repo.content.read"],
|
| 76 |
-
},
|
| 77 |
-
],
|
| 78 |
-
},
|
| 79 |
-
},
|
| 80 |
-
},
|
| 81 |
-
}),
|
| 82 |
);
|
| 83 |
|
| 84 |
-
await expect(
|
| 85 |
-
checkDatasetCredential({
|
| 86 |
-
token: "hf_readonly",
|
| 87 |
-
datasetRepo: "alice/xtap-pool-data",
|
| 88 |
-
fetchFn,
|
| 89 |
-
}),
|
| 90 |
-
).resolves.toEqual({
|
| 91 |
credential: "invalid",
|
| 92 |
-
error:
|
| 93 |
});
|
| 94 |
});
|
| 95 |
|
| 96 |
it("treats transient Hub failures as unknown so startup can retry", async () => {
|
| 97 |
const fetchFn: typeof fetch = () => Promise.resolve(new Response("oops", { status: 503 }));
|
| 98 |
|
| 99 |
-
await expect(
|
| 100 |
-
checkDatasetCredential({ token: "hf_dataset", datasetRepo: "alice/xtap-pool-data", fetchFn }),
|
| 101 |
-
).resolves.toEqual({
|
| 102 |
credential: "unknown",
|
| 103 |
error: "Hugging Face rejected HF_TOKEN (503).",
|
| 104 |
});
|
|
|
|
| 2 |
|
| 3 |
import { checkDatasetCredential } from "../src/dataset-token.js";
|
| 4 |
|
| 5 |
+
const datasetRepo = "alice/xtap-pool-data";
|
| 6 |
+
const indexBucket = "alice/xtap-pool-bucket";
|
| 7 |
+
|
| 8 |
+
function scope(name: string, permissions: readonly string[], type: string): unknown {
|
| 9 |
+
return { entity: { type, name }, permissions };
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
function whoami(scopes: readonly unknown[]): unknown {
|
| 13 |
+
return {
|
| 14 |
+
auth: {
|
| 15 |
+
accessToken: {
|
| 16 |
+
role: "fineGrained",
|
| 17 |
+
fineGrained: { global: [], scoped: scopes },
|
| 18 |
+
},
|
| 19 |
+
},
|
| 20 |
+
};
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
function validScopes(): readonly unknown[] {
|
| 24 |
+
const permissions = ["repo.content.read", "repo.content.write"];
|
| 25 |
+
return [scope(datasetRepo, permissions, "dataset"), scope(indexBucket, permissions, "bucket")];
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function check(fetchFn: typeof fetch, token = "hf_storage") {
|
| 29 |
+
return checkDatasetCredential({ token, datasetRepo, indexBucket, fetchFn });
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
describe("storage credential readiness", () => {
|
| 33 |
+
it("accepts exact dataset and Bucket read/write scopes plus direct reads", async () => {
|
| 34 |
+
const fetchFn = vi
|
| 35 |
+
.fn<typeof fetch>()
|
| 36 |
+
.mockResolvedValueOnce(Response.json(whoami(validScopes())))
|
| 37 |
+
.mockResolvedValueOnce(new Response("{}", { status: 200 }))
|
| 38 |
+
.mockResolvedValueOnce(new Response("{}", { status: 200 }));
|
| 39 |
+
|
| 40 |
+
await expect(check(fetchFn)).resolves.toEqual({ credential: "ok" });
|
| 41 |
+
});
|
| 42 |
+
|
| 43 |
+
it("rejects a token whose Bucket metadata scope is missing", async () => {
|
| 44 |
const fetchFn: typeof fetch = () =>
|
| 45 |
Promise.resolve(
|
| 46 |
+
Response.json(
|
| 47 |
+
whoami([scope(datasetRepo, ["repo.content.read", "repo.content.write"], "dataset")]),
|
| 48 |
+
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
);
|
| 50 |
|
| 51 |
+
await expect(check(fetchFn)).resolves.toEqual({
|
| 52 |
+
credential: "invalid",
|
| 53 |
+
error: `HF_TOKEN must include repo.content.read on ${indexBucket}. HF_TOKEN must include repo.content.write or repo.write on ${indexBucket}.`,
|
| 54 |
+
});
|
| 55 |
});
|
| 56 |
|
| 57 |
+
it("rejects tokens whose metadata passes but a direct storage read fails", async () => {
|
| 58 |
const fetchFn = vi
|
| 59 |
.fn<typeof fetch>()
|
| 60 |
+
.mockResolvedValueOnce(Response.json(whoami(validScopes())))
|
| 61 |
+
.mockResolvedValueOnce(new Response("{}", { status: 200 }))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
.mockResolvedValueOnce(new Response("unauthorized", { status: 401 }));
|
| 63 |
|
| 64 |
+
await expect(check(fetchFn)).resolves.toEqual({
|
|
|
|
|
|
|
| 65 |
credential: "invalid",
|
| 66 |
+
error: "Hugging Face rejected a direct private-Bucket read using HF_TOKEN (401).",
|
| 67 |
});
|
| 68 |
});
|
| 69 |
|
| 70 |
+
it("rejects storage tokens without write permission", async () => {
|
| 71 |
const fetchFn: typeof fetch = () =>
|
| 72 |
Promise.resolve(
|
| 73 |
+
Response.json(
|
| 74 |
+
whoami([
|
| 75 |
+
scope(datasetRepo, ["repo.content.read"], "dataset"),
|
| 76 |
+
scope(indexBucket, ["repo.content.read", "repo.content.write"], "bucket"),
|
| 77 |
+
]),
|
| 78 |
+
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
);
|
| 80 |
|
| 81 |
+
await expect(check(fetchFn, "hf_readonly")).resolves.toEqual({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
credential: "invalid",
|
| 83 |
+
error: `HF_TOKEN must include repo.content.write or repo.write on ${datasetRepo}.`,
|
| 84 |
});
|
| 85 |
});
|
| 86 |
|
| 87 |
it("treats transient Hub failures as unknown so startup can retry", async () => {
|
| 88 |
const fetchFn: typeof fetch = () => Promise.resolve(new Response("oops", { status: 503 }));
|
| 89 |
|
| 90 |
+
await expect(check(fetchFn)).resolves.toEqual({
|
|
|
|
|
|
|
| 91 |
credential: "unknown",
|
| 92 |
error: "Hugging Face rejected HF_TOKEN (503).",
|
| 93 |
});
|
space/tests/dataset.test.ts
CHANGED
|
@@ -4,7 +4,11 @@ import { join } from "node:path";
|
|
| 4 |
|
| 5 |
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
| 6 |
|
| 7 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
import type { HubClient } from "../src/dataset.js";
|
| 9 |
import { EnrichStore } from "../src/enrich-store.js";
|
| 10 |
import { TweetStore } from "../src/store.js";
|
|
@@ -115,6 +119,57 @@ describe("DatasetMirror.rebuild", () => {
|
|
| 115 |
});
|
| 116 |
});
|
| 117 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
describe("DatasetMirror enrichment rebuild", () => {
|
| 119 |
it("replays enrichment shards without seeding label output from legacy rows", async () => {
|
| 120 |
hub.files.set(
|
|
|
|
| 4 |
|
| 5 |
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
| 6 |
|
| 7 |
+
import {
|
| 8 |
+
assertValidDatasetSourceContent,
|
| 9 |
+
DatasetMirror,
|
| 10 |
+
parseJsonlTweets,
|
| 11 |
+
} from "../src/dataset.js";
|
| 12 |
import type { HubClient } from "../src/dataset.js";
|
| 13 |
import { EnrichStore } from "../src/enrich-store.js";
|
| 14 |
import { TweetStore } from "../src/store.js";
|
|
|
|
| 119 |
});
|
| 120 |
});
|
| 121 |
|
| 122 |
+
describe("durable source validation", () => {
|
| 123 |
+
it("rejects malformed JSON and schema-invalid records for every source kind", () => {
|
| 124 |
+
expect(() => {
|
| 125 |
+
assertValidDatasetSourceContent("data/osolmaz/2026/08/tweets-2026-08-04.jsonl", "not json\n");
|
| 126 |
+
}).toThrow("invalid JSON");
|
| 127 |
+
for (const path of [
|
| 128 |
+
"data/osolmaz/2026/08/tweets-2026-08-04.jsonl",
|
| 129 |
+
"enrichment/2026/08/enrichment-2026-08-04.jsonl",
|
| 130 |
+
"enrichment/attempts/2026/08/attempts-2026-08-04.jsonl",
|
| 131 |
+
"enrichment/registry/2026/08/registry-2026-08-04.jsonl",
|
| 132 |
+
"enrichment/receipts/2026-08-04.jsonl",
|
| 133 |
+
]) {
|
| 134 |
+
expect(() => {
|
| 135 |
+
assertValidDatasetSourceContent(path, "{}\n");
|
| 136 |
+
}).toThrow("invalid");
|
| 137 |
+
}
|
| 138 |
+
});
|
| 139 |
+
|
| 140 |
+
it("accepts recognized previous enrichment and receipt contracts", () => {
|
| 141 |
+
expect(() => {
|
| 142 |
+
assertValidDatasetSourceContent(
|
| 143 |
+
"enrichment/2026/07/enrichment-2026-07-06.jsonl",
|
| 144 |
+
`${JSON.stringify({
|
| 145 |
+
unit_id: "1:someone",
|
| 146 |
+
tweet_ids: ["1"],
|
| 147 |
+
labels: ["ai"],
|
| 148 |
+
free_labels: ["gguf"],
|
| 149 |
+
concepts: [{ name: "vLLM", aliases: [] }],
|
| 150 |
+
model: "model",
|
| 151 |
+
taxonomy_version: 1,
|
| 152 |
+
enriched_at: "2026-07-06T00:00:00.000Z",
|
| 153 |
+
})}\n`,
|
| 154 |
+
);
|
| 155 |
+
}).not.toThrow();
|
| 156 |
+
expect(() => {
|
| 157 |
+
assertValidDatasetSourceContent(
|
| 158 |
+
"enrichment/receipts/2026-07-26.jsonl",
|
| 159 |
+
`${JSON.stringify({
|
| 160 |
+
started_at: "2026-07-26T00:00:00.000Z",
|
| 161 |
+
finished_at: "2026-07-26T00:01:00.000Z",
|
| 162 |
+
units: 1,
|
| 163 |
+
calls: 1,
|
| 164 |
+
prompt_tokens: 10,
|
| 165 |
+
completion_tokens: 5,
|
| 166 |
+
failures: 0,
|
| 167 |
+
})}\n`,
|
| 168 |
+
);
|
| 169 |
+
}).not.toThrow();
|
| 170 |
+
});
|
| 171 |
+
});
|
| 172 |
+
|
| 173 |
describe("DatasetMirror enrichment rebuild", () => {
|
| 174 |
it("replays enrichment shards without seeding label output from legacy rows", async () => {
|
| 175 |
hub.files.set(
|
space/tests/durable-index.test.ts
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createHash } from "node:crypto";
|
| 2 |
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
| 3 |
+
import { tmpdir } from "node:os";
|
| 4 |
+
import { dirname, join } from "node:path";
|
| 5 |
+
|
| 6 |
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
| 7 |
+
|
| 8 |
+
import { DatasetMirror } from "../src/dataset.js";
|
| 9 |
+
import { DurableIndex, durableIndexManifestSchema } from "../src/durable-index.js";
|
| 10 |
+
import type {
|
| 11 |
+
BucketFile,
|
| 12 |
+
DatasetSnapshotClient,
|
| 13 |
+
DatasetSourceFile,
|
| 14 |
+
DurableIndexBucketClient,
|
| 15 |
+
DurableIndexOptions,
|
| 16 |
+
} from "../src/durable-index.js";
|
| 17 |
+
import { FakeHub, makePooled } from "./helpers.js";
|
| 18 |
+
|
| 19 |
+
const DATASET = "osolmaz/xtap-pool-data";
|
| 20 |
+
const BUCKET = "osolmaz/xtap-pool-bucket";
|
| 21 |
+
const CONTRACT = "a".repeat(64);
|
| 22 |
+
|
| 23 |
+
class FakeSource implements DatasetSnapshotClient {
|
| 24 |
+
revision = "1".repeat(40);
|
| 25 |
+
files = new Map<string, string>();
|
| 26 |
+
textFiles = new Map<string, string>();
|
| 27 |
+
commitErrors: unknown[] = [];
|
| 28 |
+
|
| 29 |
+
currentRevision(): Promise<string> {
|
| 30 |
+
return Promise.resolve(this.revision);
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
listJsonlFiles(): Promise<readonly DatasetSourceFile[]> {
|
| 34 |
+
return Promise.resolve(
|
| 35 |
+
[...this.files.entries()].map(([path, content]) => ({
|
| 36 |
+
path,
|
| 37 |
+
oid: sha256(content),
|
| 38 |
+
size: Buffer.byteLength(content),
|
| 39 |
+
})),
|
| 40 |
+
);
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
downloadFile(path: string): Promise<Uint8Array> {
|
| 44 |
+
const content = this.files.get(path);
|
| 45 |
+
if (content === undefined) return Promise.reject(new Error(`missing ${path}`));
|
| 46 |
+
return Promise.resolve(Buffer.from(content));
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
readText(path: string): Promise<string | undefined> {
|
| 50 |
+
return Promise.resolve(this.textFiles.get(path));
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
commitText(path: string, content: string, parentRevision: string): Promise<string> {
|
| 54 |
+
const commitError = this.commitErrors.shift();
|
| 55 |
+
if (commitError !== undefined) {
|
| 56 |
+
this.advanceRevision();
|
| 57 |
+
// Deliberately permit a non-Error rejection to cover defensive classifier behavior.
|
| 58 |
+
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
|
| 59 |
+
return Promise.reject(commitError);
|
| 60 |
+
}
|
| 61 |
+
if (parentRevision !== this.revision) {
|
| 62 |
+
return Promise.reject(new Error("parent commit does not match dataset HEAD"));
|
| 63 |
+
}
|
| 64 |
+
this.textFiles.set(path, content);
|
| 65 |
+
this.advanceRevision();
|
| 66 |
+
return Promise.resolve(this.revision);
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
advanceRevision(): void {
|
| 70 |
+
const value = Number.parseInt(this.revision.slice(0, 8), 16) + 1;
|
| 71 |
+
this.revision = value.toString(16).padStart(40, "0");
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
class FakeBucket implements DurableIndexBucketClient {
|
| 76 |
+
files = new Map<string, Buffer>();
|
| 77 |
+
uploaded = new Map<string, string>();
|
| 78 |
+
removed: string[] = [];
|
| 79 |
+
private clock = 0;
|
| 80 |
+
|
| 81 |
+
async download(path: string, destination: string): Promise<boolean> {
|
| 82 |
+
const content = this.files.get(path);
|
| 83 |
+
if (content === undefined) return false;
|
| 84 |
+
await mkdir(dirname(destination), { recursive: true });
|
| 85 |
+
await writeFile(destination, content);
|
| 86 |
+
return true;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
async uploadFile(path: string, source: string): Promise<void> {
|
| 90 |
+
this.files.set(path, await readFile(source));
|
| 91 |
+
this.clock += 1;
|
| 92 |
+
this.uploaded.set(path, String(this.clock).padStart(8, "0"));
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
list(prefix: string): Promise<readonly BucketFile[]> {
|
| 96 |
+
return Promise.resolve(
|
| 97 |
+
[...this.files.keys()]
|
| 98 |
+
.filter((path) => path.startsWith(`${prefix}/`))
|
| 99 |
+
.map((path) => {
|
| 100 |
+
const uploadedAt = this.uploaded.get(path);
|
| 101 |
+
return { path, ...(uploadedAt === undefined ? {} : { uploadedAt }) };
|
| 102 |
+
}),
|
| 103 |
+
);
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
remove(paths: readonly string[]): Promise<void> {
|
| 107 |
+
for (const path of paths) {
|
| 108 |
+
this.files.delete(path);
|
| 109 |
+
this.uploaded.delete(path);
|
| 110 |
+
this.removed.push(path);
|
| 111 |
+
}
|
| 112 |
+
return Promise.resolve();
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
let dir: string;
|
| 117 |
+
let source: FakeSource;
|
| 118 |
+
let bucket: FakeBucket;
|
| 119 |
+
let hub: FakeHub;
|
| 120 |
+
|
| 121 |
+
beforeEach(async () => {
|
| 122 |
+
dir = await mkdtemp(join(tmpdir(), "xtap-index-test-"));
|
| 123 |
+
source = new FakeSource();
|
| 124 |
+
bucket = new FakeBucket();
|
| 125 |
+
hub = new FakeHub();
|
| 126 |
+
});
|
| 127 |
+
|
| 128 |
+
afterEach(async () => {
|
| 129 |
+
await rm(dir, { recursive: true, force: true });
|
| 130 |
+
});
|
| 131 |
+
|
| 132 |
+
function options(name: string): DurableIndexOptions {
|
| 133 |
+
return {
|
| 134 |
+
datasetRepo: DATASET,
|
| 135 |
+
indexBucket: BUCKET,
|
| 136 |
+
accessToken: "hf_test",
|
| 137 |
+
databasePath: join(dir, `${name}.sqlite`),
|
| 138 |
+
mirror: new DatasetMirror(hub, join(dir, `${name}-mirror`)),
|
| 139 |
+
taxonomyVersion: 1,
|
| 140 |
+
contractHash: CONTRACT,
|
| 141 |
+
sourceClient: source,
|
| 142 |
+
bucketClient: bucket,
|
| 143 |
+
};
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
function tweetLine(id: string, author = "someone"): string {
|
| 147 |
+
return `${JSON.stringify(
|
| 148 |
+
makePooled({
|
| 149 |
+
id,
|
| 150 |
+
url: `https://x.com/${author}/status/${id}`,
|
| 151 |
+
author: { username: author, display_name: author },
|
| 152 |
+
conversation_id: id,
|
| 153 |
+
}),
|
| 154 |
+
)}\n`;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
describe("durable enrichment index", () => {
|
| 158 |
+
it("bootstraps, publishes, restores, and applies only a strict append suffix", async () => {
|
| 159 |
+
const path = "data/osolmaz/2026/08/tweets-2026-08-04.jsonl";
|
| 160 |
+
source.files.set(path, tweetLine("1"));
|
| 161 |
+
const initial = await DurableIndex.bootstrap(options("bootstrap"));
|
| 162 |
+
expect(initial.store.count()).toBe(1);
|
| 163 |
+
const firstManifest = await initial.publish();
|
| 164 |
+
initial.close();
|
| 165 |
+
|
| 166 |
+
expect(durableIndexManifestSchema.parse(firstManifest)).toEqual(firstManifest);
|
| 167 |
+
expect(() =>
|
| 168 |
+
durableIndexManifestSchema.parse({ ...firstManifest, unexpected: true }),
|
| 169 |
+
).toThrow();
|
| 170 |
+
const restoreOptions = options("restored");
|
| 171 |
+
await Promise.all([
|
| 172 |
+
writeFile(restoreOptions.databasePath, "stale database"),
|
| 173 |
+
writeFile(`${restoreOptions.databasePath}-wal`, "stale wal"),
|
| 174 |
+
writeFile(`${restoreOptions.databasePath}-shm`, "stale shm"),
|
| 175 |
+
]);
|
| 176 |
+
const restored = await DurableIndex.restore(restoreOptions);
|
| 177 |
+
expect(restored.store.count()).toBe(1);
|
| 178 |
+
source.files.set(path, `${source.files.get(path) ?? ""}${tweetLine("2", "other")}`);
|
| 179 |
+
source.advanceRevision();
|
| 180 |
+
|
| 181 |
+
const advanced = await restored.advanceToLatest();
|
| 182 |
+
expect(advanced).toMatchObject({ filesChanged: 1, rowsApplied: 1 });
|
| 183 |
+
expect(restored.store.count()).toBe(2);
|
| 184 |
+
await expect(restored.advanceToLatest()).resolves.toMatchObject({
|
| 185 |
+
filesChanged: 0,
|
| 186 |
+
rowsApplied: 0,
|
| 187 |
+
});
|
| 188 |
+
restored.close();
|
| 189 |
+
});
|
| 190 |
+
|
| 191 |
+
it("replays enrichment, attempt, registry, and receipt suffixes", async () => {
|
| 192 |
+
const tweetPath = "data/osolmaz/2026/08/tweets-2026-08-04.jsonl";
|
| 193 |
+
source.files.set(tweetPath, `${tweetLine("1")}${tweetLine("2", "other")}`);
|
| 194 |
+
const eventOptions = options("events");
|
| 195 |
+
const index = await DurableIndex.bootstrap(eventOptions);
|
| 196 |
+
const items = index.enrichStore.claimQueued(10);
|
| 197 |
+
index.enrichStore.releaseClaims();
|
| 198 |
+
const first = items.find((item) => item.tweetIds.includes("1"));
|
| 199 |
+
const second = items.find((item) => item.tweetIds.includes("2"));
|
| 200 |
+
if (first === undefined || second === undefined) throw new Error("expected queue items");
|
| 201 |
+
|
| 202 |
+
source.files.set(
|
| 203 |
+
"enrichment/2026/08/enrichment-2026-08-04.jsonl",
|
| 204 |
+
`${JSON.stringify({
|
| 205 |
+
unit_id: first.unitId,
|
| 206 |
+
tweet_ids: first.tweetIds,
|
| 207 |
+
input_hash: first.inputHash,
|
| 208 |
+
contract_hash: first.contractHash,
|
| 209 |
+
preset_labels: [],
|
| 210 |
+
free_labels: [],
|
| 211 |
+
model: "model",
|
| 212 |
+
taxonomy_version: 1,
|
| 213 |
+
enriched_at: "2026-08-04T00:00:00.000Z",
|
| 214 |
+
})}\n`,
|
| 215 |
+
);
|
| 216 |
+
source.files.set(
|
| 217 |
+
"enrichment/attempts/2026/08/attempts-2026-08-04.jsonl",
|
| 218 |
+
`${JSON.stringify({
|
| 219 |
+
unit_id: second.unitId,
|
| 220 |
+
input_hash: second.inputHash,
|
| 221 |
+
contract_hash: second.contractHash,
|
| 222 |
+
attempt: 1,
|
| 223 |
+
outcome: "transient_failure",
|
| 224 |
+
error_class: "timeout",
|
| 225 |
+
at: "2026-08-04T00:00:00.000Z",
|
| 226 |
+
})}\n`,
|
| 227 |
+
);
|
| 228 |
+
source.files.set(
|
| 229 |
+
"enrichment/registry/2026/08/registry-2026-08-04.jsonl",
|
| 230 |
+
`${JSON.stringify({
|
| 231 |
+
name: "new-label",
|
| 232 |
+
status: "candidate",
|
| 233 |
+
at: "2026-08-04T00:00:00.000Z",
|
| 234 |
+
actor: "worker",
|
| 235 |
+
contract_hash: CONTRACT,
|
| 236 |
+
registry_revision: 2,
|
| 237 |
+
})}\n`,
|
| 238 |
+
);
|
| 239 |
+
source.files.set(
|
| 240 |
+
"enrichment/receipts/2026-08-04.jsonl",
|
| 241 |
+
`${JSON.stringify(receiptFixture("job-1"))}\n`,
|
| 242 |
+
);
|
| 243 |
+
source.advanceRevision();
|
| 244 |
+
|
| 245 |
+
const advanced = await index.advanceToLatest();
|
| 246 |
+
expect(advanced.filesChanged).toBe(4);
|
| 247 |
+
expect(index.enrichStore.queueEntry(first.unitId)?.status).toBe("done");
|
| 248 |
+
expect(index.enrichStore.queueEntry(second.unitId)?.status).toBe("retrying");
|
| 249 |
+
expect(index.enrichStore.registryStatus("new-label")).toBe("candidate");
|
| 250 |
+
expect(eventOptions.mirror.latestReceipt()?.worker_id).toBe("job-1");
|
| 251 |
+
expect(index.stats()).toMatchObject({
|
| 252 |
+
enrichmentRows: 1,
|
| 253 |
+
attemptEvents: 1,
|
| 254 |
+
registryEvents: 1,
|
| 255 |
+
});
|
| 256 |
+
index.close();
|
| 257 |
+
});
|
| 258 |
+
|
| 259 |
+
it("fails closed on prefix edits, truncation, deletion, and checksum mismatch", async () => {
|
| 260 |
+
const path = "data/osolmaz/2026/08/tweets-2026-08-04.jsonl";
|
| 261 |
+
source.files.set(path, `${tweetLine("1")}${tweetLine("2")}`);
|
| 262 |
+
const index = await DurableIndex.bootstrap(options("failures"));
|
| 263 |
+
await index.publish();
|
| 264 |
+
|
| 265 |
+
source.files.set(path, `${tweetLine("1")}${tweetLine("2")}not json\n`);
|
| 266 |
+
source.advanceRevision();
|
| 267 |
+
await expect(index.advanceToLatest()).rejects.toThrow("invalid JSON");
|
| 268 |
+
source.files.set(path, `${tweetLine("1")}${tweetLine("2")}{}\n`);
|
| 269 |
+
source.advanceRevision();
|
| 270 |
+
await expect(index.advanceToLatest()).rejects.toThrow("invalid tweet record");
|
| 271 |
+
source.files.set(path, `${tweetLine("changed")}${tweetLine("2")}`);
|
| 272 |
+
source.advanceRevision();
|
| 273 |
+
await expect(index.advanceToLatest()).rejects.toThrow("prefix changed");
|
| 274 |
+
source.files.set(path, tweetLine("1"));
|
| 275 |
+
source.advanceRevision();
|
| 276 |
+
await expect(index.advanceToLatest()).rejects.toThrow("truncated");
|
| 277 |
+
source.files.delete(path);
|
| 278 |
+
source.advanceRevision();
|
| 279 |
+
await expect(index.advanceToLatest()).rejects.toThrow("were deleted");
|
| 280 |
+
source.files.set(path, tweetLine("1").trimEnd());
|
| 281 |
+
source.advanceRevision();
|
| 282 |
+
await expect(index.advanceToLatest()).rejects.toThrow("complete JSONL line");
|
| 283 |
+
source.files.set(path, `${tweetLine("1")}${tweetLine("2")}`);
|
| 284 |
+
source.files.set("data/unknown.jsonl", tweetLine("1"));
|
| 285 |
+
source.advanceRevision();
|
| 286 |
+
await expect(index.advanceToLatest()).rejects.toThrow("unsupported dataset index source");
|
| 287 |
+
index.close();
|
| 288 |
+
|
| 289 |
+
const manifest = JSON.parse(source.textFiles.get("index/current.json") ?? "{}") as {
|
| 290 |
+
database: { key: string };
|
| 291 |
+
};
|
| 292 |
+
bucket.files.set(manifest.database.key, Buffer.from("corrupt"));
|
| 293 |
+
await expect(DurableIndex.restore(options("corrupt"))).rejects.toThrow("checksum mismatch");
|
| 294 |
+
});
|
| 295 |
+
|
| 296 |
+
it("refuses an atomic manifest commit after the dataset head changes", async () => {
|
| 297 |
+
source.files.set("data/osolmaz/2026/08/tweets-2026-08-04.jsonl", tweetLine("1"));
|
| 298 |
+
const index = await DurableIndex.bootstrap(options("race"));
|
| 299 |
+
source.advanceRevision();
|
| 300 |
+
|
| 301 |
+
await expect(index.publish()).rejects.toThrow("parent commit does not match");
|
| 302 |
+
expect(source.textFiles.has("index/current.json")).toBe(false);
|
| 303 |
+
index.close();
|
| 304 |
+
});
|
| 305 |
+
|
| 306 |
+
it("re-advances and retries when the dataset changes during publication", async () => {
|
| 307 |
+
source.files.set("data/osolmaz/2026/08/tweets-2026-08-04.jsonl", tweetLine("1"));
|
| 308 |
+
const index = await DurableIndex.bootstrap(options("publication-retry"));
|
| 309 |
+
source.commitErrors.push(new Error("The branch was updated since publication started"));
|
| 310 |
+
|
| 311 |
+
const published = await index.publishLatest();
|
| 312 |
+
|
| 313 |
+
expect(published.manifest.dataset.revision).toBe(published.advance.revision);
|
| 314 |
+
expect(source.textFiles.has("index/current.json")).toBe(true);
|
| 315 |
+
expect(bucket.files.has(published.manifest.database.key)).toBe(true);
|
| 316 |
+
index.close();
|
| 317 |
+
});
|
| 318 |
+
|
| 319 |
+
it("retries status-code conflicts but rejects unrelated and exhausted failures", async () => {
|
| 320 |
+
source.files.set("data/osolmaz/2026/08/tweets-2026-08-04.jsonl", tweetLine("1"));
|
| 321 |
+
const index = await DurableIndex.bootstrap(options("publication-errors"));
|
| 322 |
+
source.commitErrors.push(Object.assign(new Error("conflict"), { statusCode: 409 }));
|
| 323 |
+
await expect(index.publishLatest()).resolves.toHaveProperty("manifest.dataset.repo", DATASET);
|
| 324 |
+
|
| 325 |
+
source.commitErrors.push(new Error("permission denied"));
|
| 326 |
+
await expect(index.publishLatest()).rejects.toThrow("permission denied");
|
| 327 |
+
source.commitErrors.push("non-error rejection");
|
| 328 |
+
await expect(index.publishLatest()).rejects.toBe("non-error rejection");
|
| 329 |
+
|
| 330 |
+
source.commitErrors.push(
|
| 331 |
+
...Array.from(
|
| 332 |
+
{ length: 5 },
|
| 333 |
+
() => new Error("The branch was updated since publication started"),
|
| 334 |
+
),
|
| 335 |
+
);
|
| 336 |
+
await expect(index.publishLatest()).rejects.toThrow("branch was updated");
|
| 337 |
+
index.close();
|
| 338 |
+
});
|
| 339 |
+
|
| 340 |
+
it("does not let failed publication uploads displace successful predecessors", async () => {
|
| 341 |
+
const path = "data/osolmaz/2026/08/tweets-2026-08-04.jsonl";
|
| 342 |
+
source.files.set(path, tweetLine("1"));
|
| 343 |
+
const index = await DurableIndex.bootstrap(options("orphan-retention"));
|
| 344 |
+
const first = await index.publish();
|
| 345 |
+
for (let generation = 2; generation <= 5; generation += 1) {
|
| 346 |
+
source.files.set(path, `${source.files.get(path) ?? ""}${tweetLine(String(generation))}`);
|
| 347 |
+
source.advanceRevision();
|
| 348 |
+
await index.advanceToLatest();
|
| 349 |
+
source.commitErrors.push(new Error("permission denied"));
|
| 350 |
+
await expect(index.publish()).rejects.toThrow("permission denied");
|
| 351 |
+
}
|
| 352 |
+
source.files.set(path, `${source.files.get(path) ?? ""}${tweetLine("6")}`);
|
| 353 |
+
source.advanceRevision();
|
| 354 |
+
await index.advanceToLatest();
|
| 355 |
+
const final = await index.publish();
|
| 356 |
+
|
| 357 |
+
expect(final.database.predecessors).toEqual([first.database.key]);
|
| 358 |
+
expect([...bucket.files.keys()].filter((key) => key.endsWith(".sqlite")).sort()).toEqual(
|
| 359 |
+
[first.database.key, final.database.key].sort(),
|
| 360 |
+
);
|
| 361 |
+
index.close();
|
| 362 |
+
});
|
| 363 |
+
|
| 364 |
+
it("retains the active database and three recent predecessors", async () => {
|
| 365 |
+
const path = "data/osolmaz/2026/08/tweets-2026-08-04.jsonl";
|
| 366 |
+
source.files.set(path, tweetLine("1"));
|
| 367 |
+
const index = await DurableIndex.bootstrap(options("retention"));
|
| 368 |
+
for (let generation = 0; generation < 6; generation += 1) {
|
| 369 |
+
if (generation > 0) {
|
| 370 |
+
source.files.set(
|
| 371 |
+
path,
|
| 372 |
+
`${source.files.get(path) ?? ""}${tweetLine(String(generation + 1))}`,
|
| 373 |
+
);
|
| 374 |
+
source.advanceRevision();
|
| 375 |
+
await index.advanceToLatest();
|
| 376 |
+
}
|
| 377 |
+
await index.publish();
|
| 378 |
+
}
|
| 379 |
+
const databases = [...bucket.files.keys()].filter((key) => key.startsWith("index/databases/"));
|
| 380 |
+
expect(databases).toHaveLength(4);
|
| 381 |
+
const current = JSON.parse(source.textFiles.get("index/current.json") ?? "{}") as {
|
| 382 |
+
database: { key: string; predecessors: string[] };
|
| 383 |
+
};
|
| 384 |
+
expect(current.database.predecessors).toHaveLength(3);
|
| 385 |
+
expect(databases.sort()).toEqual(
|
| 386 |
+
[current.database.key, ...current.database.predecessors].sort(),
|
| 387 |
+
);
|
| 388 |
+
expect(bucket.removed).toHaveLength(2);
|
| 389 |
+
index.close();
|
| 390 |
+
});
|
| 391 |
+
});
|
| 392 |
+
|
| 393 |
+
function receiptFixture(workerId: string): Record<string, unknown> {
|
| 394 |
+
return {
|
| 395 |
+
started_at: "2026-08-04T00:00:00.000Z",
|
| 396 |
+
finished_at: "2026-08-04T00:01:00.000Z",
|
| 397 |
+
units: 1,
|
| 398 |
+
calls: 1,
|
| 399 |
+
prompt_tokens: 1,
|
| 400 |
+
completion_tokens: 1,
|
| 401 |
+
cost_usd: 0.00001,
|
| 402 |
+
failures: 0,
|
| 403 |
+
retries: 0,
|
| 404 |
+
blocked: 0,
|
| 405 |
+
contract_hash: CONTRACT,
|
| 406 |
+
worker_id: workerId,
|
| 407 |
+
discarded_assignments: 0,
|
| 408 |
+
new_candidates: 0,
|
| 409 |
+
new_approvals: 0,
|
| 410 |
+
new_rejections: 0,
|
| 411 |
+
};
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
function sha256(value: string): string {
|
| 415 |
+
return createHash("sha256").update(value).digest("hex");
|
| 416 |
+
}
|
space/tests/helpers.ts
CHANGED
|
@@ -7,6 +7,7 @@ export const testConfig: SpaceConfig = {
|
|
| 7 |
port: 7860,
|
| 8 |
dataDir: ".data-test",
|
| 9 |
datasetRepo: "osolmaz/xtap-pool-data",
|
|
|
|
| 10 |
hfToken: "hf_test_token",
|
| 11 |
poolSigningSecret: "pool-secret-0123456789abcdef0123456789abcdef",
|
| 12 |
sessionSecret: "session-secret-0123456789abcdef0123456789ab",
|
|
|
|
| 7 |
port: 7860,
|
| 8 |
dataDir: ".data-test",
|
| 9 |
datasetRepo: "osolmaz/xtap-pool-data",
|
| 10 |
+
indexBucket: "osolmaz/xtap-pool-bucket",
|
| 11 |
hfToken: "hf_test_token",
|
| 12 |
poolSigningSecret: "pool-secret-0123456789abcdef0123456789abcdef",
|
| 13 |
sessionSecret: "session-secret-0123456789abcdef0123456789ab",
|