Add 0pt Iceberg write-path ownership: dataset<->table mapping + source rate policy
Real, tested implementation of docs/11-lakehouse-write-plan.md's items 2-8 (the "future implementation session" that doc named): 0pt is now the canonical writer of Iceberg tables through ST0RE's real, shipped /v1/st0re/lakehouse/* table registry (sax3l/st0re commit 9ea0abb), never persisting lakehouse table/catalog/snapshot state itself. - src/types + src/services for 0pt.dataset_iceberg_mapping.schema.v1 (dataset -> ST0RE tableId reference) and 0pt.source_rate_policy.schema.v1 (the sourceRatePolicy ownership repair, moved out of N0D per ADR-0030). - src/clients/st0re-lakehouse-client.ts: new client matching ST0RE's actual camelCase route contract, with the same mock/HTTP boundary convention as this repo's existing cl0ud/n0d/aud0/st0re clients. Honest caveat in its module doc comment: not verified against a live ST0RE instance in this pass (no reachable ST0RE_URL); every test exercises the deterministic mock. - src/services/iceberg-write-service.ts: registers tables/snapshots through ST0RE, requests N0D jobs only for heavy writes (never executes them in-process), emits all four AUD0 event names (dataset_iceberg_table_mapped / iceberg_write_requested / iceberg_snapshot_created / source_rate_policy_created). - sql/004, packages/schemas/*.json, app/api/0pt/lakehouse + sources/rate-policies routes, tests/p0/*.test.ts (154 passed + 1 honest it.todo for the live-ST0RE case, up from 142 baseline, 0 failures). - Reconciled a real collision: an earlier untracked draft schema/test pair (tests/p0/enterprise-data-os-expansion.test.ts) asserted a different, now-superseded source_rate_policy shape; updated that one test block only. - docs/ICEBERG_WRITE_BOUNDARY.md states the ownership boundary plainly and is explicit throughout about what's real-and-tested vs. contract-only. docs/11-lakehouse-write-plan.md gets a status-update section, not a rewrite. - Data quality rules (quality-check-service.ts, quality-report-service.ts) were not touched -- confirmed still 0pt-owned. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { DatasetIcebergMappingService } from "@/src/services/dataset-iceberg-mapping-service";
|
||||
|
||||
const mappingService = new DatasetIcebergMappingService();
|
||||
|
||||
export async function GET(_request: Request, { params }: { params: Promise<{ mappingId: string }> }) {
|
||||
try {
|
||||
const { mappingId } = await params;
|
||||
const mapping = await mappingService.getMapping(mappingId);
|
||||
return NextResponse.json(mapping);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return NextResponse.json({ error: message }, { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { IcebergWriteService } from "@/src/services/iceberg-write-service";
|
||||
|
||||
const writeService = new IcebergWriteService();
|
||||
|
||||
// Registers a new Iceberg snapshot (a write) through ST0RE's table registry
|
||||
// for an already-mapped dataset. See docs/ICEBERG_WRITE_BOUNDARY.md.
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ mappingId: string }> }) {
|
||||
try {
|
||||
const { mappingId } = await params;
|
||||
const body = await request.json();
|
||||
const result = await writeService.requestIcebergWrite({
|
||||
dataset_iceberg_mapping_id: mappingId,
|
||||
actor_id: body.actor_id,
|
||||
correlation_id: body.correlation_id,
|
||||
snapshot_type: body.snapshot_type,
|
||||
schema_version: body.schema_version,
|
||||
manifest_uri: body.manifest_uri,
|
||||
record_count: body.record_count,
|
||||
size_bytes: body.size_bytes,
|
||||
heavy: body.heavy,
|
||||
});
|
||||
return NextResponse.json(result, { status: 202 });
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { IcebergWriteService } from "@/src/services/iceberg-write-service";
|
||||
import { DatasetIcebergMappingService } from "@/src/services/dataset-iceberg-mapping-service";
|
||||
|
||||
// Shared instances so a mapping created via POST is visible to a later GET
|
||||
// within the same server process, matching the in-memory-per-module
|
||||
// convention already used by app/api/0pt/datasets and app/api/0pt/quality
|
||||
// (Postgres persistence exists in sql/ but is not wired into route handlers
|
||||
// in this pass -- same limitation those routes already have).
|
||||
const mappingService = new DatasetIcebergMappingService();
|
||||
const writeService = new IcebergWriteService({ mappingService });
|
||||
|
||||
// POST registers a NEW Iceberg table in ST0RE's table registry (via
|
||||
// IcebergWriteService.mapDatasetToIcebergTable) and records the
|
||||
// dataset_iceberg_mapping row that points at it -- this is 0pt's write-path
|
||||
// entrypoint, see docs/ICEBERG_WRITE_BOUNDARY.md.
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const result = await writeService.mapDatasetToIcebergTable({
|
||||
tenant_id: body.tenant_id,
|
||||
project_id: body.project_id,
|
||||
dataset_id: body.dataset_id,
|
||||
dataset_iceberg_mapping_id: body.dataset_iceberg_mapping_id,
|
||||
namespace: body.namespace,
|
||||
table_name: body.table_name,
|
||||
storage_uri: body.storage_uri,
|
||||
catalog_ref: body.catalog_ref,
|
||||
table_format: body.table_format,
|
||||
actor_id: body.actor_id,
|
||||
correlation_id: body.correlation_id,
|
||||
});
|
||||
return NextResponse.json(result, { status: 201 });
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const mappings = await mappingService.listMappings({
|
||||
tenant_id: searchParams.get("tenant_id") ?? undefined,
|
||||
project_id: searchParams.get("project_id") ?? undefined,
|
||||
dataset_id: searchParams.get("dataset_id") ?? undefined,
|
||||
});
|
||||
return NextResponse.json(mappings);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { SourceRatePolicyService } from "@/src/services/source-rate-policy-service";
|
||||
|
||||
const service = new SourceRatePolicyService();
|
||||
|
||||
export async function GET(_request: Request, { params }: { params: Promise<{ sourceRatePolicyId: string }> }) {
|
||||
try {
|
||||
const { sourceRatePolicyId } = await params;
|
||||
const policy = await service.getSourceRatePolicy(sourceRatePolicyId);
|
||||
return NextResponse.json(policy);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return NextResponse.json({ error: message }, { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { SourceRatePolicyService } from "@/src/services/source-rate-policy-service";
|
||||
|
||||
const service = new SourceRatePolicyService();
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const policy = await service.createSourceRatePolicy(body);
|
||||
return NextResponse.json(policy, { status: 201 });
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const policies = await service.listSourceRatePolicies({
|
||||
tenant_id: searchParams.get("tenant_id") ?? undefined,
|
||||
project_id: searchParams.get("project_id") ?? undefined,
|
||||
source_id: searchParams.get("source_id") ?? undefined,
|
||||
});
|
||||
return NextResponse.json(policies);
|
||||
}
|
||||
@@ -5,6 +5,49 @@ implementation session — it names the schemas/events precisely so that session
|
||||
guess, but it does not build the full pipeline today. Some items are already true today (quality
|
||||
rules) and this doc just confirms 0pt intends to keep them that way.
|
||||
|
||||
## 2026-07-12 update: what the 0pt write-boundary implementation pass closed
|
||||
|
||||
This pass (the "future implementation session" this doc was written for) turned most items below
|
||||
into real, tested code. Full detail in `docs/ICEBERG_WRITE_BOUNDARY.md`; short version:
|
||||
|
||||
- Item 2 (`0pt.dataset_iceberg_mapping`): CLOSED as real code -- `src/types/dataset-iceberg-mapping.ts`,
|
||||
`src/services/dataset-iceberg-mapping-service.ts`, `packages/schemas/0pt.dataset_iceberg_mapping.schema.json`,
|
||||
`sql/004_iceberg_write_boundary.sql` (`opt_dataset_iceberg_mappings`), tested in
|
||||
`tests/p0/dataset-iceberg-mapping.test.ts`. Field names differ slightly from the "suggested shape"
|
||||
above (`dataset_iceberg_mapping_id` as PK, `iceberg_table_id` instead of `iceberg_table_ref`,
|
||||
`mapping_status` instead of bare `entity_type`/`market`) -- reconciled against ST0RE's REAL
|
||||
shipped `IcebergTable.tableId` shape (sax3l/st0re, not the "future" registry this doc assumed) and
|
||||
against this session's own task spec; see the boundary doc for the full reconciliation note.
|
||||
- Item 3 (first test dataset write): CLOSED against ST0RE's now-real (but not live-verified from
|
||||
this environment) `/v1/st0re/lakehouse/*` API -- `src/clients/st0re-lakehouse-client.ts`,
|
||||
`src/services/iceberg-write-service.ts`, proven in `tests/p0/iceberg-write-service.test.ts`
|
||||
against that client's mock implementation (same mock/HTTP convention as `cl0ud-client.ts` etc).
|
||||
NOT verified against a running ST0RE deployment in this pass -- see the boundary doc's
|
||||
"Verification status" section and the `it.todo` in that test file.
|
||||
- Item 4 (quality rules stay in 0pt): CONFIRMED, untouched -- `src/services/quality-check-service.ts`
|
||||
and `src/services/quality-report-service.ts` were not modified by this pass.
|
||||
- Item 5 (`0pt.source_rate_policy`): CLOSED as real code -- `src/types/source-rate-policy.ts`,
|
||||
`src/services/source-rate-policy-service.ts`, `packages/schemas/0pt.source_rate_policy.schema.json`,
|
||||
`sql/004_iceberg_write_boundary.sql` (`opt_source_rate_policies`), tested in
|
||||
`tests/p0/source-rate-policy.test.ts`. Kept `requests_per_second`-style fields (as `domain`/
|
||||
`robots_ok`/`access`) as OPTIONAL alongside the task-spec's primary `rate_limit_per_minute`/
|
||||
`crawl_budget`/`compliance_notes` fields -- both specs for this same contract are satisfied on one
|
||||
row, not two competing shapes.
|
||||
- Item 6 (four AUD0 events): CLOSED for all four names -- `0pt.dataset_iceberg_table_mapped` and
|
||||
`0pt.iceberg_write_requested`/`0pt.iceberg_snapshot_created` emit from
|
||||
`src/services/iceberg-write-service.ts` and `dataset-iceberg-mapping-service.ts`;
|
||||
`0pt.source_rate_policy_created` emits from `src/services/source-rate-policy-service.ts`. All via
|
||||
the existing `Aud0Client` (mock/no-op by default, same as every other AUD0 emission in this repo --
|
||||
AUD0 itself still has no live ingestion endpoint).
|
||||
- Item 7 (N0D heavy-write requests): CLOSED as real code -- `IcebergWriteService.requestIcebergWrite`
|
||||
requests an N0D job (`0pt.iceberg_write_job`) only when `heavy: true`, never executes in-process;
|
||||
tested in `tests/p0/iceberg-write-service.test.ts`.
|
||||
- Item 8 (no lakehouse state outside ST0RE): CLOSED as a design property, not just a statement --
|
||||
`IcebergWriteService` and `DatasetIcebergMappingService` hold zero table/snapshot fields; the only
|
||||
thing 0pt persists is the mapping's `iceberg_table_id` reference. See the boundary doc.
|
||||
- Item 1 (Iceberg canonical, Delta rejected): CONFIRMED, unchanged from `docs/10` --
|
||||
`IcebergWriteService.mapDatasetToIcebergTable` defaults `table_format` to `"iceberg"`.
|
||||
|
||||
## To-do list
|
||||
|
||||
1. **Use Apache Iceberg as the canonical write format** for datasets 0pt produces, per
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# Iceberg write-path ownership boundary (0pt)
|
||||
|
||||
Read `docs/10-lakehouse-decision.md` and `docs/11-lakehouse-write-plan.md` first -- this doc is
|
||||
the implementation record for the plan those wrote. It states the ownership boundary plainly and
|
||||
says exactly what in this pass is real code vs. contract-only, matching the honesty convention
|
||||
already used across this SIAX effort (e.g. sax3l/act0's own client docs, sax3l/st0re's
|
||||
PRODUCTION_READINESS.md).
|
||||
|
||||
## The boundary, plainly
|
||||
|
||||
- **ST0RE owns table/catalog/artifact metadata for the lakehouse.** The Iceberg catalog, table
|
||||
registry, namespace registry, and snapshot registry live in ST0RE (sax3l/st0re,
|
||||
`src/api/routes/lakehouse.ts`, `/v1/st0re/lakehouse/*`). 0pt does not run a catalog, does not
|
||||
keep a second copy of table/snapshot rows, and does not talk to MinIO or an Iceberg REST catalog
|
||||
directly.
|
||||
- **0pt owns dataset production, quality, data contracts, and writes.** 0pt runs sourcing ->
|
||||
extraction -> structuring -> enrichment -> QA (`docs/06-data-engine.md`) as it always has, and is
|
||||
the canonical writer of the Iceberg tables those datasets produce -- but it writes THROUGH
|
||||
ST0RE's table registry API, never around it.
|
||||
- **0pt owns source-rate/crawl-compliance policy.** This is the genuine ownership repair this pass
|
||||
makes concrete: `0pt.source_rate_policy` (per-source rate limits, crawl budgets, compliance
|
||||
notes) is 0pt's own contract. It does not exist in, and must not be reintroduced into, N0D's
|
||||
`ComputePolicy.sourceRatePolicy` (deprecated there per the ADR-0030 audit), CL0UD, or any other
|
||||
product's policy defaults.
|
||||
- **N0D executes.** 0pt may request an N0D job for a heavy write or compaction
|
||||
(`IcebergWriteService.requestIcebergWrite({ heavy: true })`), but N0D never owns the schema, the
|
||||
catalog state, or the decision of what to write -- and 0pt never persists the result of that job
|
||||
anywhere except by registering the resulting snapshot through ST0RE.
|
||||
- **0pt never persists lakehouse table/catalog/snapshot state outside ST0RE.** Not in Postgres
|
||||
(`opt_*` tables), not in memory beyond a single dataset -> table-id pointer
|
||||
(`opt_dataset_iceberg_mappings.iceberg_table_id`, a REFERENCE, never a copy of ST0RE's row).
|
||||
Standing up a parallel catalog in 0pt would repeat the exact mistake this whole cross-repo effort
|
||||
is repairing (N0D's own bootstrap catalog, built because nothing else had one yet -- see
|
||||
sax3l/st0re `docs/LAKEHOUSE_OWNERSHIP_PLAN.md`).
|
||||
|
||||
## What this pass built
|
||||
|
||||
| Concern | File(s) | Status |
|
||||
|---|---|---|
|
||||
| `0pt.dataset_iceberg_mapping.schema.v1` type | `src/types/dataset-iceberg-mapping.ts` | Real |
|
||||
| `0pt.source_rate_policy.schema.v1` type | `src/types/source-rate-policy.ts` | Real |
|
||||
| Dataset<->Iceberg-table mapping service | `src/services/dataset-iceberg-mapping-service.ts` | Real, tested |
|
||||
| Source rate policy service | `src/services/source-rate-policy-service.ts` | Real, tested |
|
||||
| ST0RE lakehouse HTTP/mock client | `src/clients/st0re-lakehouse-client.ts` | Real code, contract matches ST0RE's shipped API; **not live-verified** (see below) |
|
||||
| Write orchestration (map table, request write) | `src/services/iceberg-write-service.ts` | Real, tested against the mock client |
|
||||
| JSON Schemas | `packages/schemas/0pt.dataset_iceberg_mapping.schema.json`, `packages/schemas/0pt.source_rate_policy.schema.json` | Real |
|
||||
| Postgres migration | `sql/004_iceberg_write_boundary.sql` | Real (not applied to a live DB by this pass -- same as `sql/002`/`sql/003`, which this repo's own P0 docs already note are schema-only until `DATABASE_URL` wiring lands) |
|
||||
| API routes | `app/api/0pt/lakehouse/mappings/route.ts` (+`[mappingId]`, `+writes`), `app/api/0pt/sources/rate-policies/route.ts` (+`[sourceRatePolicyId]`) | Real, follow the existing `app/api/0pt/*` convention |
|
||||
| Tests | `tests/p0/dataset-iceberg-mapping.test.ts`, `tests/p0/source-rate-policy.test.ts`, `tests/p0/iceberg-write-service.test.ts` | Real, all passing (`pnpm test`); one explicit `it.todo` for the live-ST0RE case |
|
||||
|
||||
## Verification status (real vs. contract-only -- read this before trusting any claim above)
|
||||
|
||||
- **Real and tested**: every TypeScript file above typechecks and its behavior is exercised by a
|
||||
passing vitest test in `tests/p0/`. The full suite (`pnpm test`) passed at 154 tests + 1 todo
|
||||
after this pass, up from a 142-test baseline before it, with 0 failures.
|
||||
- **Real code, not live-verified**: `src/clients/st0re-lakehouse-client.ts`'s HTTP path is written
|
||||
against ST0RE's actual, committed, shipped route contract (sax3l/st0re commit `9ea0abb`,
|
||||
`src/api/routes/lakehouse.ts` + `src/types.ts` -- read directly from that repo's source during
|
||||
this pass, not guessed). ST0RE's own `STATUS.md` documents it as deployed via Coolify on port
|
||||
8090. This pass did not have a confirmed, reachable `ST0RE_URL` to call, so the HTTP path has
|
||||
**not been executed against a running ST0RE instance**. Every test in this pass exercises the
|
||||
client's deterministic in-memory mock instead -- the exact same convention `cl0ud-client.ts`,
|
||||
`n0d-client.ts`, `aud0-client.ts`, and `st0re-client.ts` already use in this repo for the same
|
||||
reason (no live CL0UD/N0D/AUD0/ST0RE endpoint is reachable from this repo's test environment
|
||||
either). This is not a downgrade from the rest of P0 -- it is the same maturity level as
|
||||
everything else in `src/services/quality-check-service.ts`.
|
||||
- **Contract-only / explicit TODO**: a live-ST0RE integration test is named but not built --
|
||||
`tests/p0/iceberg-write-service.test.ts`'s `it.todo(...)` line. A future session with a reachable
|
||||
`ST0RE_URL` should fill it in by constructing `IcebergWriteService({ st0reBaseUrl:
|
||||
process.env.ST0RE_URL })` and asserting the same flow against the real HTTP API.
|
||||
- **Not built at all in this pass**: actual Iceberg data-file writes (Parquet bytes, manifest
|
||||
generation). `IcebergWriteService.requestIcebergWrite` registers a snapshot POINTER through
|
||||
ST0RE (`manifestUri`, `recordCount`, `sizeBytes` supplied by the caller) -- it does not produce
|
||||
those bytes itself. This mirrors ST0RE's own documented stance on its snapshot route ("ST0RE
|
||||
doesn't itself write the Iceberg metadata.json... this registry tracks the pointer/version
|
||||
history for whatever the caller's own catalog write produced" -- `src/api/routes/lakehouse.ts` in
|
||||
sax3l/st0re). Producing the actual bytes is a distinct, larger piece of work for a future session
|
||||
once a real Iceberg writer library and MinIO credentials are wired into 0pt's engine
|
||||
(`services/api`).
|
||||
|
||||
## A test collision this pass found and fixed
|
||||
|
||||
`tests/p0/enterprise-data-os-expansion.test.ts` (untracked, pre-existing, from an earlier "PROMPT
|
||||
2" pass) had one test asserting `packages/schemas/0pt.source_rate_policy.schema.json`'s `required`
|
||||
array contained `requests_per_second`/`burst_limit` and its `priority` enum contained `critical` --
|
||||
the OLD draft shape for this same schema file. This pass's canonical fields
|
||||
(`rate_limit_per_minute`/`crawl_budget`/`compliance_notes`, per this session's explicit task spec)
|
||||
replaced that draft, which would have silently broken that one pre-existing test. Fixed by updating
|
||||
that single test block (not the rest of the file) to assert the new canonical fields instead --
|
||||
`pnpm test` is 154 passed + 1 todo, 0 failures, after this pass (up from a 142-test baseline before
|
||||
it). See that test file's inline comment for the same note.
|
||||
|
||||
## A pre-existing artifact this pass did not touch
|
||||
|
||||
`packages/schemas/0pt.iceberg_table.schema.json` already existed (untracked, un-pretty-printed) in
|
||||
this repo's working tree before this pass started -- it appears to be an earlier, different attempt
|
||||
at this same concern that mirrors ST0RE's full `IcebergTable` row shape (`storage_uri`,
|
||||
`catalog_ref`, `current_snapshot_id`, etc.) rather than a lightweight reference. If it is ever
|
||||
adopted, it must be documented as a non-authoritative READ CACHE only -- 0pt must never treat it as
|
||||
the source of truth or write to it independently of ST0RE. This pass's actual, owned contract is
|
||||
`0pt.dataset_iceberg_mapping.schema.v1` above; it was left in place, unmodified, rather than
|
||||
deleted or silently rewritten, since it wasn't part of this task's scope and this repo has other,
|
||||
concurrent work in flight (`STATUS.md`, the wider uncommitted P0 data-platform build) that this
|
||||
pass did not want to disturb outside its own files.
|
||||
|
||||
## Event names (all four wired, docs/11 item 6)
|
||||
|
||||
| Event | Emitted by | When |
|
||||
|---|---|---|
|
||||
| `0pt.dataset_iceberg_table_mapped` | `DatasetIcebergMappingService.createMapping` | A dataset -> Iceberg-table mapping is created |
|
||||
| `0pt.iceberg_write_requested` | `IcebergWriteService.requestIcebergWrite` | Before 0pt calls ST0RE to register a snapshot |
|
||||
| `0pt.iceberg_snapshot_created` | `IcebergWriteService.requestIcebergWrite` | After ST0RE confirms the new snapshot |
|
||||
| `0pt.source_rate_policy_created` | `SourceRatePolicyService.createSourceRatePolicy` | A new source rate policy is created |
|
||||
|
||||
All four go through the existing `Aud0Client` (`src/clients/aud0-client.ts`) -- no-op by default,
|
||||
same as every other AUD0 emission already in this repo (AUD0 has no live ingestion endpoint yet).
|
||||
|
||||
## Data quality stays in 0pt (confirmed, not moved)
|
||||
|
||||
This pass did not modify `src/services/quality-check-service.ts`, `src/services/quality-report-service.ts`,
|
||||
`src/types/quality-check.ts`, or `src/types/quality-report.ts`. 0pt's QA stage
|
||||
(`docs/06-data-engine.md`) remains entirely 0pt-owned; nothing in this pass routes quality logic
|
||||
through N0D, CL0UD, or ST0RE. N0D may still be asked to EXECUTE a quality-check job
|
||||
(`quality-check-service.ts`'s existing `n0d.createJob` call, unrelated to this pass), but the rules
|
||||
being executed are always 0pt's own.
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"$id": "0pt.dataset_iceberg_mapping.schema.v1",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"dataset_iceberg_mapping_id",
|
||||
"tenant_id",
|
||||
"project_id",
|
||||
"dataset_id",
|
||||
"iceberg_table_id",
|
||||
"mapping_status",
|
||||
"created_at"
|
||||
],
|
||||
"properties": {
|
||||
"dataset_iceberg_mapping_id": { "type": "string" },
|
||||
"tenant_id": { "type": "string" },
|
||||
"project_id": { "type": "string" },
|
||||
"dataset_id": { "type": "string", "description": "References 0pt.dataset.schema.v1's dataset_id." },
|
||||
"iceberg_table_id": {
|
||||
"type": "string",
|
||||
"description": "Reference into ST0RE's table registry (IcebergTable.tableId, st0re_iceberg_tables). Never a copy of ST0RE's table row."
|
||||
},
|
||||
"namespace": { "type": "string", "description": "ST0RE lakehouse namespace, carried for convenience/filtering only." },
|
||||
"table_name": { "type": "string" },
|
||||
"mapping_status": { "type": "string", "enum": ["pending", "active", "deprecated", "archived"] },
|
||||
"mapped_by": { "type": "string" },
|
||||
"correlation_id": { "type": "string" },
|
||||
"created_at": { "type": "string" },
|
||||
"updated_at": { "type": "string" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"$id": "0pt.source_rate_policy.schema.v1",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"source_rate_policy_id",
|
||||
"tenant_id",
|
||||
"project_id",
|
||||
"source_id",
|
||||
"rate_limit_per_minute",
|
||||
"crawl_budget",
|
||||
"status",
|
||||
"created_at"
|
||||
],
|
||||
"properties": {
|
||||
"source_rate_policy_id": { "type": "string" },
|
||||
"tenant_id": { "type": "string" },
|
||||
"project_id": { "type": "string" },
|
||||
"source_id": { "type": "string", "description": "References 0pt.source.schema.v1's source_id." },
|
||||
"rate_limit_per_minute": { "type": "number" },
|
||||
"crawl_budget": { "type": "number", "description": "Max requests/pages/records this policy permits per crawl run or per day; unit documented in compliance_notes when non-obvious." },
|
||||
"compliance_notes": { "type": "string" },
|
||||
"domain": { "type": "string", "description": "Crawl-target domain, e.g. allabolag.se." },
|
||||
"robots_ok": { "type": "boolean" },
|
||||
"access": { "type": "string", "enum": ["api", "bulk", "authorized_web", "licensed"] },
|
||||
"status": { "type": "string", "enum": ["active", "paused", "deprecated"] },
|
||||
"correlation_id": { "type": "string" },
|
||||
"created_at": { "type": "string" },
|
||||
"updated_at": { "type": "string" },
|
||||
"created_by": { "type": "string" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
-- 0pt Iceberg write-path ownership repair (docs/ICEBERG_WRITE_BOUNDARY.md)
|
||||
-- Two tables: a REFERENCE from a 0pt dataset to its Iceberg table in ST0RE's
|
||||
-- table registry, and 0pt's own source-rate/crawl-compliance policy (moved
|
||||
-- out of N0D per ADR-0030's audit -- see docs/10-lakehouse-decision.md).
|
||||
--
|
||||
-- opt_dataset_iceberg_mappings.iceberg_table_id is intentionally NOT a
|
||||
-- foreign key -- it references ST0RE's st0re_iceberg_tables.table_id, a
|
||||
-- different service's database. 0pt holds only the reference, never a copy
|
||||
-- of ST0RE's table/catalog/snapshot state (see the module doc comment on
|
||||
-- src/clients/st0re-lakehouse-client.ts).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS opt_dataset_iceberg_mappings (
|
||||
dataset_iceberg_mapping_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
dataset_id TEXT NOT NULL REFERENCES opt_datasets (dataset_id),
|
||||
iceberg_table_id TEXT NOT NULL,
|
||||
namespace TEXT,
|
||||
table_name TEXT,
|
||||
mapping_status TEXT NOT NULL DEFAULT 'active',
|
||||
mapped_by TEXT,
|
||||
correlation_id TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS opt_dataset_iceberg_mappings_dataset_idx ON opt_dataset_iceberg_mappings (dataset_id);
|
||||
CREATE INDEX IF NOT EXISTS opt_dataset_iceberg_mappings_tenant_project_idx ON opt_dataset_iceberg_mappings (tenant_id, project_id);
|
||||
CREATE INDEX IF NOT EXISTS opt_dataset_iceberg_mappings_table_idx ON opt_dataset_iceberg_mappings (iceberg_table_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS opt_source_rate_policies (
|
||||
source_rate_policy_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
source_id TEXT NOT NULL REFERENCES opt_sources (source_id),
|
||||
rate_limit_per_minute NUMERIC NOT NULL,
|
||||
crawl_budget NUMERIC NOT NULL,
|
||||
compliance_notes TEXT,
|
||||
domain TEXT,
|
||||
robots_ok BOOLEAN,
|
||||
access TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
correlation_id TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ,
|
||||
created_by TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS opt_source_rate_policies_source_idx ON opt_source_rate_policies (source_id);
|
||||
CREATE INDEX IF NOT EXISTS opt_source_rate_policies_tenant_project_idx ON opt_source_rate_policies (tenant_id, project_id);
|
||||
@@ -0,0 +1,185 @@
|
||||
// ST0RE lakehouse client -- 0pt's ONLY path for writing Iceberg table/
|
||||
// snapshot state. 0pt must never persist lakehouse table/catalog/snapshot
|
||||
// state anywhere else (see docs/ICEBERG_WRITE_BOUNDARY.md).
|
||||
//
|
||||
// Distinct from `./st0re-client.ts` (the generic Artifact registry client,
|
||||
// snake_case request shape) because it targets a different, newer route
|
||||
// family on the SAME ST0RE service: `/v1/st0re/lakehouse/*`
|
||||
// (sax3l/st0re `src/api/routes/lakehouse.ts`, commit 9ea0abb). That route
|
||||
// family's Fastify handlers parse camelCase JSON bodies matching ST0RE's own
|
||||
// `IcebergTableInput`/`TableSnapshotInput`/`LakehouseNamespaceInput` types
|
||||
// (sax3l/st0re `src/types.ts`) -- this client's request/response shapes are
|
||||
// camelCase deliberately, to match ST0RE's real contract byte-for-byte
|
||||
// rather than 0pt's own snake_case domain convention. The service layer
|
||||
// (`src/services/dataset-iceberg-mapping-service.ts`,
|
||||
// `src/services/iceberg-write-service.ts`) is what translates between 0pt's
|
||||
// snake_case types and this client's camelCase wire shape.
|
||||
//
|
||||
// HONESTY NOTE (not live-verified): ST0RE's lakehouse routes are real,
|
||||
// committed code in sax3l/st0re (verified by reading that repo's source in
|
||||
// this same pass) and are documented as deployed via Coolify at port 8090
|
||||
// (sax3l/st0re STATUS.md), but this pass did not confirm the deployment is
|
||||
// currently reachable from this environment (no ST0RE_URL was available to
|
||||
// probe). Exactly like `cl0ud-client.ts`/`aud0-client.ts`/`n0d-client.ts`/
|
||||
// `st0re-client.ts` already do in this repo, this client falls back to a
|
||||
// deterministic in-memory mock when no base URL is configured, so callers
|
||||
// (and this pass's tests) exercise the real request/response shape and
|
||||
// control flow without requiring a live ST0RE instance. Set ST0RE_URL (or
|
||||
// pass a baseUrl) to point this at a real deployment; the HTTP path is
|
||||
// written against the documented contract above but has not been executed
|
||||
// against a running ST0RE in this pass.
|
||||
|
||||
export type IcebergTableFormat = "iceberg" | "delta" | "hudi";
|
||||
export type LakehouseObjectStatus = "active" | "deprecated" | "archived";
|
||||
export type TableSnapshotType = "append" | "overwrite" | "delete" | "replace" | "schema_change" | "compaction";
|
||||
|
||||
export interface St0reLakehouseNamespaceInput {
|
||||
tenantId: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
ownerProduct: string;
|
||||
}
|
||||
|
||||
export interface St0reLakehouseNamespace extends St0reLakehouseNamespaceInput {
|
||||
namespaceId: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface St0reIcebergTableInput {
|
||||
tenantId: string;
|
||||
projectId: string;
|
||||
namespace: string;
|
||||
tableName: string;
|
||||
tableFormat: IcebergTableFormat;
|
||||
storageUri: string;
|
||||
/** Opaque reference into the external Iceberg catalog -- ST0RE does not resolve it. */
|
||||
catalogRef: string;
|
||||
datasetId?: string;
|
||||
ownerProduct: string;
|
||||
schemaId?: string;
|
||||
partitionSpec?: readonly string[];
|
||||
retentionPolicy?: string;
|
||||
dataResidency?: string;
|
||||
piiLevel?: "none" | "low" | "medium" | "high";
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
export interface St0reIcebergTable extends St0reIcebergTableInput {
|
||||
tableId: string;
|
||||
currentSnapshotId?: string;
|
||||
status: LakehouseObjectStatus;
|
||||
createdAt: number;
|
||||
artifactId?: string;
|
||||
}
|
||||
|
||||
export interface St0reTableSnapshotInput {
|
||||
snapshotType: TableSnapshotType;
|
||||
schemaVersion: string;
|
||||
/** URI of the Iceberg manifest list/file for this snapshot -- 0pt/N0D writes the actual bytes; ST0RE only tracks the pointer. */
|
||||
manifestUri: string;
|
||||
createdByJobId?: string;
|
||||
createdByCapability?: string;
|
||||
recordCount?: number;
|
||||
sizeBytes?: number;
|
||||
aud0EventId?: string;
|
||||
}
|
||||
|
||||
export interface St0reTableSnapshot extends St0reTableSnapshotInput {
|
||||
snapshotId: string;
|
||||
tableId: string;
|
||||
tenantId: string;
|
||||
projectId: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface St0reLakehouseClient {
|
||||
registerNamespace(input: St0reLakehouseNamespaceInput): Promise<St0reLakehouseNamespace>;
|
||||
registerTable(input: St0reIcebergTableInput): Promise<St0reIcebergTable>;
|
||||
getTable(tableId: string): Promise<St0reIcebergTable>;
|
||||
createSnapshot(tableId: string, input: St0reTableSnapshotInput): Promise<St0reTableSnapshot>;
|
||||
}
|
||||
|
||||
export function createSt0reLakehouseClient(baseUrl?: string): St0reLakehouseClient {
|
||||
if (!baseUrl) return new MockSt0reLakehouseClient();
|
||||
return new HttpSt0reLakehouseClient(baseUrl);
|
||||
}
|
||||
|
||||
class MockSt0reLakehouseClient implements St0reLakehouseClient {
|
||||
private tables: Map<string, St0reIcebergTable> = new Map();
|
||||
|
||||
async registerNamespace(input: St0reLakehouseNamespaceInput): Promise<St0reLakehouseNamespace> {
|
||||
return { ...input, namespaceId: `mock_ns_${Date.now()}`, createdAt: Date.now() };
|
||||
}
|
||||
|
||||
async registerTable(input: St0reIcebergTableInput): Promise<St0reIcebergTable> {
|
||||
const table: St0reIcebergTable = {
|
||||
...input,
|
||||
tableId: `mock_table_${Date.now()}`,
|
||||
status: "active",
|
||||
piiLevel: input.piiLevel ?? "none",
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
this.tables.set(table.tableId, table);
|
||||
return table;
|
||||
}
|
||||
|
||||
async getTable(tableId: string): Promise<St0reIcebergTable> {
|
||||
const table = this.tables.get(tableId);
|
||||
if (!table) throw new Error(`ST0RE mock: table ${tableId} not found`);
|
||||
return table;
|
||||
}
|
||||
|
||||
async createSnapshot(tableId: string, input: St0reTableSnapshotInput): Promise<St0reTableSnapshot> {
|
||||
const table = this.tables.get(tableId);
|
||||
const snapshotId = `mock_snapshot_${Date.now()}`;
|
||||
if (table) table.currentSnapshotId = snapshotId;
|
||||
return {
|
||||
...input,
|
||||
snapshotId,
|
||||
tableId,
|
||||
tenantId: table?.tenantId ?? "unknown",
|
||||
projectId: table?.projectId ?? "unknown",
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class HttpSt0reLakehouseClient implements St0reLakehouseClient {
|
||||
constructor(private baseUrl: string) {}
|
||||
|
||||
async registerNamespace(input: St0reLakehouseNamespaceInput): Promise<St0reLakehouseNamespace> {
|
||||
const res = await fetch(`${this.baseUrl}/v1/st0re/lakehouse/namespaces`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) throw new Error(`ST0RE lakehouse namespace error: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async registerTable(input: St0reIcebergTableInput): Promise<St0reIcebergTable> {
|
||||
const res = await fetch(`${this.baseUrl}/v1/st0re/lakehouse/tables`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) throw new Error(`ST0RE lakehouse table error: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async getTable(tableId: string): Promise<St0reIcebergTable> {
|
||||
const res = await fetch(`${this.baseUrl}/v1/st0re/lakehouse/tables/${tableId}`);
|
||||
if (!res.ok) throw new Error(`ST0RE lakehouse get-table error: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async createSnapshot(tableId: string, input: St0reTableSnapshotInput): Promise<St0reTableSnapshot> {
|
||||
const res = await fetch(`${this.baseUrl}/v1/st0re/lakehouse/tables/${tableId}/snapshots`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) throw new Error(`ST0RE lakehouse snapshot error: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import type {
|
||||
DatasetIcebergMapping,
|
||||
DatasetIcebergMappingStatus,
|
||||
CreateDatasetIcebergMappingInput,
|
||||
} from "../types/dataset-iceberg-mapping";
|
||||
import { createAud0Client, type Aud0Client } from "../clients/aud0-client";
|
||||
import { DatasetService } from "./dataset-service";
|
||||
|
||||
interface DatasetIcebergMappingRecord {
|
||||
dataset_iceberg_mapping_id: string;
|
||||
tenant_id: string;
|
||||
project_id: string;
|
||||
dataset_id: string;
|
||||
iceberg_table_id: string;
|
||||
namespace: string | null;
|
||||
table_name: string | null;
|
||||
mapping_status: string;
|
||||
mapped_by: string | null;
|
||||
correlation_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns 0pt.dataset_iceberg_mapping.schema.v1 -- the pointer from a 0pt
|
||||
* dataset to the Iceberg table it writes to. `iceberg_table_id` is always a
|
||||
* REFERENCE into ST0RE's table registry (never a copy of ST0RE's table row);
|
||||
* this service holds no table/catalog/snapshot state itself. See
|
||||
* docs/ICEBERG_WRITE_BOUNDARY.md.
|
||||
*
|
||||
* This service does not call ST0RE directly -- it records a mapping to an
|
||||
* `iceberg_table_id` that already exists in ST0RE's registry.
|
||||
* `IcebergWriteService.mapDatasetToIcebergTable` (src/services/
|
||||
* iceberg-write-service.ts) is the higher-level flow that registers a NEW
|
||||
* table in ST0RE first and then calls this service to record the mapping --
|
||||
* kept as two composable steps so a mapping can also be created against an
|
||||
* already-registered table (e.g. one N0D or another 0pt job already set up).
|
||||
*/
|
||||
export class DatasetIcebergMappingService {
|
||||
private mappings: Map<string, DatasetIcebergMappingRecord> = new Map();
|
||||
private aud0: Aud0Client;
|
||||
private datasetService: DatasetService;
|
||||
|
||||
constructor(opts?: { aud0BaseUrl?: string; datasetService?: DatasetService }) {
|
||||
this.aud0 = createAud0Client(opts?.aud0BaseUrl);
|
||||
this.datasetService = opts?.datasetService ?? new DatasetService(opts?.aud0BaseUrl);
|
||||
}
|
||||
|
||||
async createMapping(input: CreateDatasetIcebergMappingInput): Promise<DatasetIcebergMapping> {
|
||||
if (this.mappings.has(input.dataset_iceberg_mapping_id)) {
|
||||
throw new Error(`Dataset Iceberg mapping ${input.dataset_iceberg_mapping_id} already exists`);
|
||||
}
|
||||
if (!input.iceberg_table_id) {
|
||||
throw new Error("iceberg_table_id is required (reference into ST0RE's table registry)");
|
||||
}
|
||||
// Verify the dataset exists -- a mapping with no dataset is meaningless.
|
||||
try {
|
||||
await this.datasetService.getDataset(input.dataset_id);
|
||||
} catch {
|
||||
throw new Error(`Dataset ${input.dataset_id} not found`);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const record: DatasetIcebergMappingRecord = {
|
||||
dataset_iceberg_mapping_id: input.dataset_iceberg_mapping_id,
|
||||
tenant_id: input.tenant_id,
|
||||
project_id: input.project_id,
|
||||
dataset_id: input.dataset_id,
|
||||
iceberg_table_id: input.iceberg_table_id,
|
||||
namespace: input.namespace ?? null,
|
||||
table_name: input.table_name ?? null,
|
||||
mapping_status: input.mapping_status ?? "active",
|
||||
mapped_by: input.mapped_by ?? null,
|
||||
correlation_id: input.correlation_id ?? null,
|
||||
created_at: now,
|
||||
updated_at: null,
|
||||
};
|
||||
this.mappings.set(input.dataset_iceberg_mapping_id, record);
|
||||
|
||||
// 0pt.dataset_iceberg_table_mapped -- docs/11-lakehouse-write-plan.md event list,
|
||||
// docs/ICEBERG_WRITE_BOUNDARY.md. Fired when the mapping is created/updated.
|
||||
await this.aud0.ingestEvent({
|
||||
event_id: `evt_0pt_dataset_iceberg_table_mapped_${input.dataset_iceberg_mapping_id}`,
|
||||
event_type: "0pt.dataset_iceberg_table_mapped",
|
||||
tenant_id: input.tenant_id,
|
||||
project_id: input.project_id,
|
||||
actor_id: input.mapped_by ?? "system",
|
||||
app_id: "0pt",
|
||||
capability_id: "0pt.map_dataset_iceberg_table",
|
||||
resource_type: "dataset_iceberg_mapping",
|
||||
resource_id: input.dataset_iceberg_mapping_id,
|
||||
risk_level: "L2",
|
||||
payload: {
|
||||
dataset_id: input.dataset_id,
|
||||
iceberg_table_id: input.iceberg_table_id,
|
||||
namespace: input.namespace,
|
||||
table_name: input.table_name,
|
||||
},
|
||||
created_at: now,
|
||||
correlation_id: input.correlation_id ?? "",
|
||||
});
|
||||
|
||||
// Lineage: dataset -> iceberg table.
|
||||
await this.aud0.ingestLineageEvent({
|
||||
lineage_event_id: `lineage_0pt_dataset_iceberg_${input.dataset_iceberg_mapping_id}`,
|
||||
tenant_id: input.tenant_id,
|
||||
project_id: input.project_id,
|
||||
app_id: "0pt",
|
||||
capability_id: "0pt.map_dataset_iceberg_table",
|
||||
source_type: "dataset",
|
||||
source_id: input.dataset_id,
|
||||
target_type: "iceberg_table",
|
||||
target_id: input.iceberg_table_id,
|
||||
lineage_type: "stored_as",
|
||||
metadata: { dataset_iceberg_mapping_id: input.dataset_iceberg_mapping_id },
|
||||
created_at: now,
|
||||
correlation_id: input.correlation_id ?? "",
|
||||
});
|
||||
|
||||
return this.toMapping(record);
|
||||
}
|
||||
|
||||
async getMapping(mappingId: string): Promise<DatasetIcebergMapping> {
|
||||
const record = this.mappings.get(mappingId);
|
||||
if (!record) throw new Error(`Dataset Iceberg mapping ${mappingId} not found`);
|
||||
return this.toMapping(record);
|
||||
}
|
||||
|
||||
async getMappingForDataset(datasetId: string): Promise<DatasetIcebergMapping | undefined> {
|
||||
const record = Array.from(this.mappings.values()).find((r) => r.dataset_id === datasetId && r.mapping_status === "active");
|
||||
return record ? this.toMapping(record) : undefined;
|
||||
}
|
||||
|
||||
async listMappings(filter?: {
|
||||
tenant_id?: string;
|
||||
project_id?: string;
|
||||
dataset_id?: string;
|
||||
mapping_status?: DatasetIcebergMappingStatus;
|
||||
}): Promise<DatasetIcebergMapping[]> {
|
||||
let results = Array.from(this.mappings.values());
|
||||
if (filter?.tenant_id) results = results.filter((r) => r.tenant_id === filter.tenant_id);
|
||||
if (filter?.project_id) results = results.filter((r) => r.project_id === filter.project_id);
|
||||
if (filter?.dataset_id) results = results.filter((r) => r.dataset_id === filter.dataset_id);
|
||||
if (filter?.mapping_status) results = results.filter((r) => r.mapping_status === filter.mapping_status);
|
||||
return results.map((r) => this.toMapping(r));
|
||||
}
|
||||
|
||||
private toMapping(r: DatasetIcebergMappingRecord): DatasetIcebergMapping {
|
||||
return {
|
||||
dataset_iceberg_mapping_id: r.dataset_iceberg_mapping_id,
|
||||
tenant_id: r.tenant_id,
|
||||
project_id: r.project_id,
|
||||
dataset_id: r.dataset_id,
|
||||
iceberg_table_id: r.iceberg_table_id,
|
||||
namespace: r.namespace ?? undefined,
|
||||
table_name: r.table_name ?? undefined,
|
||||
mapping_status: r.mapping_status as DatasetIcebergMappingStatus,
|
||||
mapped_by: r.mapped_by ?? undefined,
|
||||
correlation_id: r.correlation_id ?? undefined,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at ?? undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import type { DatasetIcebergMapping } from "../types/dataset-iceberg-mapping";
|
||||
import { createCl0udClient, type Cl0udClient } from "../clients/cl0ud-client";
|
||||
import { createN0dClient, type N0dClient } from "../clients/n0d-client";
|
||||
import { createAud0Client, type Aud0Client } from "../clients/aud0-client";
|
||||
import {
|
||||
createSt0reLakehouseClient,
|
||||
type St0reLakehouseClient,
|
||||
type IcebergTableFormat,
|
||||
type TableSnapshotType,
|
||||
} from "../clients/st0re-lakehouse-client";
|
||||
import { DatasetService } from "./dataset-service";
|
||||
import { DatasetIcebergMappingService } from "./dataset-iceberg-mapping-service";
|
||||
|
||||
/**
|
||||
* IcebergWriteService -- 0pt's write-path entrypoint for Iceberg tables.
|
||||
*
|
||||
* This is the concrete implementation of docs/ICEBERG_WRITE_BOUNDARY.md /
|
||||
* docs/11-lakehouse-write-plan.md items 1, 3, 6, 7, 8: 0pt is the canonical
|
||||
* writer of Iceberg tables, and it writes THROUGH ST0RE's table registry
|
||||
* (`St0reLakehouseClient`) rather than persisting any lakehouse table,
|
||||
* catalog, or snapshot state itself. This service holds NO table/snapshot
|
||||
* state of its own -- only `DatasetIcebergMappingService`'s lightweight
|
||||
* dataset-to-table-id pointer, which is a reference, not a copy.
|
||||
*
|
||||
* Two entry points:
|
||||
* - `mapDatasetToIcebergTable` -- registers a NEW Iceberg table in ST0RE for
|
||||
* a dataset that does not have one yet, then records the mapping. This is
|
||||
* the "first test dataset written to Iceberg through ST0RE's table
|
||||
* registry" proof path (see tests/p0/iceberg-write-service.test.ts).
|
||||
* - `requestIcebergWrite` -- registers a new snapshot (an actual write) for
|
||||
* an already-mapped dataset, optionally routed through an N0D job when the
|
||||
* write is heavy (item 7: 0pt REQUESTS N0D jobs for heavy writes/
|
||||
* compactions, it does not execute them itself, and it never owns the
|
||||
* result of that job beyond the ST0RE-registered snapshot ref).
|
||||
*
|
||||
* Byte-level writes (actually producing Parquet/Iceberg data files) are OUT
|
||||
* OF SCOPE for this pass, same honest boundary ST0RE itself documents for
|
||||
* its own snapshot routes ("ST0RE doesn't itself write the Iceberg
|
||||
* metadata.json... this registry tracks the pointer/version history for
|
||||
* whatever the caller's own catalog write produced" --
|
||||
* sax3l/st0re src/api/routes/lakehouse.ts). What this service proves is the
|
||||
* OWNERSHIP path -- that 0pt requests the write and registers its result
|
||||
* through ST0RE, never anywhere else.
|
||||
*/
|
||||
export interface MapDatasetToIcebergTableInput {
|
||||
tenant_id: string;
|
||||
project_id: string;
|
||||
dataset_id: string;
|
||||
dataset_iceberg_mapping_id: string;
|
||||
namespace: string;
|
||||
table_name: string;
|
||||
storage_uri: string;
|
||||
catalog_ref: string;
|
||||
table_format?: IcebergTableFormat;
|
||||
actor_id: string;
|
||||
correlation_id: string;
|
||||
}
|
||||
|
||||
export interface MapDatasetToIcebergTableResult {
|
||||
mapping: DatasetIcebergMapping;
|
||||
iceberg_table_id: string;
|
||||
policy_decision_id: string;
|
||||
}
|
||||
|
||||
export interface RequestIcebergWriteInput {
|
||||
dataset_iceberg_mapping_id: string;
|
||||
actor_id: string;
|
||||
correlation_id: string;
|
||||
snapshot_type?: TableSnapshotType;
|
||||
schema_version: string;
|
||||
manifest_uri: string;
|
||||
record_count?: number;
|
||||
size_bytes?: number;
|
||||
/** True for large backfills/table maintenance -- routes through an N0D job request rather than 0pt executing in-process (docs/11-lakehouse-write-plan.md item 7). */
|
||||
heavy?: boolean;
|
||||
}
|
||||
|
||||
export interface RequestIcebergWriteResult {
|
||||
snapshot_id: string;
|
||||
iceberg_table_id: string;
|
||||
policy_decision_id: string;
|
||||
n0d_job_id?: string;
|
||||
}
|
||||
|
||||
export class IcebergWriteService {
|
||||
private cl0ud: Cl0udClient;
|
||||
private n0d: N0dClient;
|
||||
private st0reLakehouse: St0reLakehouseClient;
|
||||
private aud0: Aud0Client;
|
||||
private datasetService: DatasetService;
|
||||
private mappingService: DatasetIcebergMappingService;
|
||||
|
||||
constructor(opts?: {
|
||||
cl0udBaseUrl?: string;
|
||||
n0dBaseUrl?: string;
|
||||
st0reBaseUrl?: string;
|
||||
aud0BaseUrl?: string;
|
||||
datasetService?: DatasetService;
|
||||
mappingService?: DatasetIcebergMappingService;
|
||||
}) {
|
||||
this.cl0ud = createCl0udClient(opts?.cl0udBaseUrl);
|
||||
this.n0d = createN0dClient(opts?.n0dBaseUrl);
|
||||
this.st0reLakehouse = createSt0reLakehouseClient(opts?.st0reBaseUrl);
|
||||
this.aud0 = createAud0Client(opts?.aud0BaseUrl);
|
||||
this.datasetService = opts?.datasetService ?? new DatasetService(opts?.aud0BaseUrl);
|
||||
this.mappingService = opts?.mappingService ?? new DatasetIcebergMappingService({ aud0BaseUrl: opts?.aud0BaseUrl, datasetService: this.datasetService });
|
||||
}
|
||||
|
||||
async mapDatasetToIcebergTable(input: MapDatasetToIcebergTableInput): Promise<MapDatasetToIcebergTableResult> {
|
||||
const dataset = await this.datasetService.getDataset(input.dataset_id); // throws if missing
|
||||
|
||||
// CL0UD policy boundary -- same L3 gate ST0RE's own table-registration
|
||||
// route applies (sax3l/st0re src/api/routes/lakehouse.ts,
|
||||
// 'st0re.lakehouse.write_table'), checked here too since 0pt is the
|
||||
// caller initiating the write, not just relying on ST0RE to gate it.
|
||||
const policy = await this.cl0ud.evaluatePolicy({
|
||||
tenant_id: input.tenant_id,
|
||||
project_id: input.project_id,
|
||||
actor_id: input.actor_id,
|
||||
capability_id: "0pt.write_iceberg_table",
|
||||
resource_type: "dataset",
|
||||
resource_id: input.dataset_id,
|
||||
risk_level: "L3",
|
||||
correlation_id: input.correlation_id,
|
||||
});
|
||||
if (policy.decision === "deny") throw new Error(`Policy denied: ${policy.reason}`);
|
||||
if (policy.decision === "approval_required") throw new Error(`Policy requires approval: ${policy.reason}`);
|
||||
|
||||
// Register the table THROUGH ST0RE's table registry -- 0pt does not
|
||||
// create or track lakehouse table state on its own.
|
||||
const table = await this.st0reLakehouse.registerTable({
|
||||
tenantId: input.tenant_id,
|
||||
projectId: input.project_id,
|
||||
namespace: input.namespace,
|
||||
tableName: input.table_name,
|
||||
tableFormat: input.table_format ?? "iceberg", // ADR-0030: Iceberg is canonical; Delta/Hudi accepted only for bridge/import scenarios, never chosen by default here.
|
||||
storageUri: input.storage_uri,
|
||||
catalogRef: input.catalog_ref,
|
||||
datasetId: input.dataset_id,
|
||||
ownerProduct: "0pt",
|
||||
createdBy: input.actor_id,
|
||||
});
|
||||
|
||||
const mapping = await this.mappingService.createMapping({
|
||||
dataset_iceberg_mapping_id: input.dataset_iceberg_mapping_id,
|
||||
tenant_id: input.tenant_id,
|
||||
project_id: input.project_id,
|
||||
dataset_id: input.dataset_id,
|
||||
iceberg_table_id: table.tableId,
|
||||
namespace: input.namespace,
|
||||
table_name: input.table_name,
|
||||
mapping_status: "active",
|
||||
mapped_by: input.actor_id,
|
||||
correlation_id: input.correlation_id,
|
||||
});
|
||||
|
||||
return { mapping, iceberg_table_id: table.tableId, policy_decision_id: policy.decision_id };
|
||||
}
|
||||
|
||||
async requestIcebergWrite(input: RequestIcebergWriteInput): Promise<RequestIcebergWriteResult> {
|
||||
const mapping = await this.mappingService.getMapping(input.dataset_iceberg_mapping_id);
|
||||
if (mapping.mapping_status !== "active") {
|
||||
throw new Error(`Dataset Iceberg mapping ${input.dataset_iceberg_mapping_id} is not active (status=${mapping.mapping_status})`);
|
||||
}
|
||||
|
||||
const policy = await this.cl0ud.evaluatePolicy({
|
||||
tenant_id: mapping.tenant_id,
|
||||
project_id: mapping.project_id,
|
||||
actor_id: input.actor_id,
|
||||
capability_id: "0pt.write_iceberg_table",
|
||||
resource_type: "dataset",
|
||||
resource_id: mapping.dataset_id,
|
||||
risk_level: "L3",
|
||||
correlation_id: input.correlation_id,
|
||||
});
|
||||
if (policy.decision === "deny") throw new Error(`Policy denied: ${policy.reason}`);
|
||||
if (policy.decision === "approval_required") throw new Error(`Policy requires approval: ${policy.reason}`);
|
||||
|
||||
// 0pt.iceberg_write_requested -- fired when 0pt requests the write,
|
||||
// before the write lands (docs/11-lakehouse-write-plan.md event list).
|
||||
await this.aud0.ingestEvent({
|
||||
event_id: `evt_0pt_iceberg_write_requested_${input.dataset_iceberg_mapping_id}_${Date.now()}`,
|
||||
event_type: "0pt.iceberg_write_requested",
|
||||
tenant_id: mapping.tenant_id,
|
||||
project_id: mapping.project_id,
|
||||
actor_id: input.actor_id,
|
||||
app_id: "0pt",
|
||||
capability_id: "0pt.write_iceberg_table",
|
||||
resource_type: "iceberg_table",
|
||||
resource_id: mapping.iceberg_table_id,
|
||||
risk_level: "L3",
|
||||
payload: { dataset_id: mapping.dataset_id, dataset_iceberg_mapping_id: mapping.dataset_iceberg_mapping_id, heavy: input.heavy ?? false },
|
||||
created_at: new Date().toISOString(),
|
||||
correlation_id: input.correlation_id,
|
||||
});
|
||||
|
||||
// Heavy writes/compactions are REQUESTED from N0D, never executed
|
||||
// in-process by 0pt (docs/11-lakehouse-write-plan.md item 7). N0D
|
||||
// executes; it does not own the schema or the decision of what to
|
||||
// write -- the snapshot registration below still goes through ST0RE
|
||||
// regardless of who performed the write.
|
||||
let n0dJobId: string | undefined;
|
||||
if (input.heavy) {
|
||||
const job = await this.n0d.createJob({
|
||||
tenant_id: mapping.tenant_id,
|
||||
project_id: mapping.project_id,
|
||||
job_type: "0pt.iceberg_write_job",
|
||||
capability_id: "0pt.write_iceberg_table",
|
||||
requested_by_app_id: "0pt",
|
||||
requested_by_actor_id: input.actor_id,
|
||||
input_dataset_ids: [mapping.dataset_id],
|
||||
output_artifact_type: "iceberg_table_snapshot",
|
||||
requires_backbone: true,
|
||||
requires_gpu: false,
|
||||
required_node_roles: ["compute_general"],
|
||||
resource_profile: "cpu_io_large",
|
||||
risk_level: "L3",
|
||||
correlation_id: input.correlation_id,
|
||||
});
|
||||
n0dJobId = job.job_id;
|
||||
}
|
||||
|
||||
// Register the snapshot THROUGH ST0RE's table registry -- the one and
|
||||
// only place 0pt persists that a write landed.
|
||||
const snapshot = await this.st0reLakehouse.createSnapshot(mapping.iceberg_table_id, {
|
||||
snapshotType: input.snapshot_type ?? "append",
|
||||
schemaVersion: input.schema_version,
|
||||
manifestUri: input.manifest_uri,
|
||||
createdByJobId: n0dJobId,
|
||||
createdByCapability: "0pt.write_iceberg_table",
|
||||
recordCount: input.record_count,
|
||||
sizeBytes: input.size_bytes,
|
||||
});
|
||||
|
||||
// 0pt.iceberg_snapshot_created -- fired once the write lands and ST0RE
|
||||
// has registered a new snapshot, carrying table ref + snapshot id + row
|
||||
// count + dataset id for AUD0 to build source -> dataset -> snapshot
|
||||
// lineage.
|
||||
await this.aud0.ingestEvent({
|
||||
event_id: `evt_0pt_iceberg_snapshot_created_${snapshot.snapshotId}`,
|
||||
event_type: "0pt.iceberg_snapshot_created",
|
||||
tenant_id: mapping.tenant_id,
|
||||
project_id: mapping.project_id,
|
||||
actor_id: input.actor_id,
|
||||
app_id: "0pt",
|
||||
capability_id: "0pt.write_iceberg_table",
|
||||
resource_type: "iceberg_snapshot",
|
||||
resource_id: snapshot.snapshotId,
|
||||
risk_level: "L2",
|
||||
payload: {
|
||||
dataset_id: mapping.dataset_id,
|
||||
iceberg_table_id: mapping.iceberg_table_id,
|
||||
snapshot_id: snapshot.snapshotId,
|
||||
record_count: input.record_count,
|
||||
n0d_job_id: n0dJobId,
|
||||
},
|
||||
created_at: new Date().toISOString(),
|
||||
correlation_id: input.correlation_id,
|
||||
});
|
||||
|
||||
await this.aud0.ingestLineageEvent({
|
||||
lineage_event_id: `lineage_0pt_iceberg_snapshot_${snapshot.snapshotId}`,
|
||||
tenant_id: mapping.tenant_id,
|
||||
project_id: mapping.project_id,
|
||||
app_id: "0pt",
|
||||
capability_id: "0pt.write_iceberg_table",
|
||||
source_type: "dataset",
|
||||
source_id: mapping.dataset_id,
|
||||
target_type: "iceberg_snapshot",
|
||||
target_id: snapshot.snapshotId,
|
||||
lineage_type: "produced",
|
||||
metadata: { iceberg_table_id: mapping.iceberg_table_id, n0d_job_id: n0dJobId },
|
||||
created_at: new Date().toISOString(),
|
||||
correlation_id: input.correlation_id,
|
||||
});
|
||||
|
||||
return {
|
||||
snapshot_id: snapshot.snapshotId,
|
||||
iceberg_table_id: mapping.iceberg_table_id,
|
||||
policy_decision_id: policy.decision_id,
|
||||
n0d_job_id: n0dJobId,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { SourceRatePolicy, SourceRatePolicyStatus, CreateSourceRatePolicyInput } from "../types/source-rate-policy";
|
||||
import { createAud0Client, type Aud0Client } from "../clients/aud0-client";
|
||||
import { SourceService } from "./source-service";
|
||||
|
||||
interface SourceRatePolicyRecord {
|
||||
source_rate_policy_id: string;
|
||||
tenant_id: string;
|
||||
project_id: string;
|
||||
source_id: string;
|
||||
rate_limit_per_minute: number;
|
||||
crawl_budget: number;
|
||||
compliance_notes: string | null;
|
||||
domain: string | null;
|
||||
robots_ok: boolean | null;
|
||||
access: string | null;
|
||||
status: string;
|
||||
correlation_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
created_by: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns 0pt.source_rate_policy.schema.v1 -- the genuine ownership repair:
|
||||
* source/crawl-domain rate limiting is 0pt's own compliance policy, moved
|
||||
* OUT of N0D's ComputePolicy.sourceRatePolicy (deprecated there per
|
||||
* ADR-0030's audit). See docs/ICEBERG_WRITE_BOUNDARY.md.
|
||||
*/
|
||||
export class SourceRatePolicyService {
|
||||
private policies: Map<string, SourceRatePolicyRecord> = new Map();
|
||||
private aud0: Aud0Client;
|
||||
private sourceService: SourceService;
|
||||
|
||||
constructor(opts?: { aud0BaseUrl?: string; sourceService?: SourceService }) {
|
||||
this.aud0 = createAud0Client(opts?.aud0BaseUrl);
|
||||
this.sourceService = opts?.sourceService ?? new SourceService(opts?.aud0BaseUrl);
|
||||
}
|
||||
|
||||
async createSourceRatePolicy(input: CreateSourceRatePolicyInput): Promise<SourceRatePolicy> {
|
||||
if (this.policies.has(input.source_rate_policy_id)) {
|
||||
throw new Error(`Source rate policy ${input.source_rate_policy_id} already exists`);
|
||||
}
|
||||
if (input.rate_limit_per_minute <= 0) {
|
||||
throw new Error("rate_limit_per_minute must be > 0");
|
||||
}
|
||||
if (input.crawl_budget <= 0) {
|
||||
throw new Error("crawl_budget must be > 0");
|
||||
}
|
||||
// Verify the source exists -- a rate policy with no source is meaningless.
|
||||
try {
|
||||
await this.sourceService.getSource(input.source_id);
|
||||
} catch {
|
||||
throw new Error(`Source ${input.source_id} not found`);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const record: SourceRatePolicyRecord = {
|
||||
source_rate_policy_id: input.source_rate_policy_id,
|
||||
tenant_id: input.tenant_id,
|
||||
project_id: input.project_id,
|
||||
source_id: input.source_id,
|
||||
rate_limit_per_minute: input.rate_limit_per_minute,
|
||||
crawl_budget: input.crawl_budget,
|
||||
compliance_notes: input.compliance_notes ?? null,
|
||||
domain: input.domain ?? null,
|
||||
robots_ok: input.robots_ok ?? null,
|
||||
access: input.access ?? null,
|
||||
status: input.status ?? "active",
|
||||
correlation_id: input.correlation_id ?? null,
|
||||
created_at: now,
|
||||
updated_at: null,
|
||||
created_by: input.created_by ?? null,
|
||||
};
|
||||
this.policies.set(input.source_rate_policy_id, record);
|
||||
|
||||
// 0pt.source_rate_policy_created -- docs/11-lakehouse-write-plan.md event list,
|
||||
// docs/ICEBERG_WRITE_BOUNDARY.md. Gives AUD0/CL0UD an evented trail of 0pt's
|
||||
// crawl-compliance posture without polling 0pt's DB.
|
||||
await this.aud0.ingestEvent({
|
||||
event_id: `evt_0pt_source_rate_policy_created_${input.source_rate_policy_id}`,
|
||||
event_type: "0pt.source_rate_policy_created",
|
||||
tenant_id: input.tenant_id,
|
||||
project_id: input.project_id,
|
||||
actor_id: input.created_by ?? "system",
|
||||
app_id: "0pt",
|
||||
capability_id: "0pt.create_source_rate_policy",
|
||||
resource_type: "source_rate_policy",
|
||||
resource_id: input.source_rate_policy_id,
|
||||
risk_level: "L2",
|
||||
payload: {
|
||||
source_id: input.source_id,
|
||||
rate_limit_per_minute: input.rate_limit_per_minute,
|
||||
crawl_budget: input.crawl_budget,
|
||||
},
|
||||
created_at: now,
|
||||
correlation_id: input.correlation_id ?? "",
|
||||
});
|
||||
|
||||
return this.toPolicy(record);
|
||||
}
|
||||
|
||||
async getSourceRatePolicy(sourceRatePolicyId: string): Promise<SourceRatePolicy> {
|
||||
const record = this.policies.get(sourceRatePolicyId);
|
||||
if (!record) throw new Error(`Source rate policy ${sourceRatePolicyId} not found`);
|
||||
return this.toPolicy(record);
|
||||
}
|
||||
|
||||
async listSourceRatePolicies(filter?: {
|
||||
tenant_id?: string;
|
||||
project_id?: string;
|
||||
source_id?: string;
|
||||
status?: SourceRatePolicyStatus;
|
||||
}): Promise<SourceRatePolicy[]> {
|
||||
let results = Array.from(this.policies.values());
|
||||
if (filter?.tenant_id) results = results.filter((r) => r.tenant_id === filter.tenant_id);
|
||||
if (filter?.project_id) results = results.filter((r) => r.project_id === filter.project_id);
|
||||
if (filter?.source_id) results = results.filter((r) => r.source_id === filter.source_id);
|
||||
if (filter?.status) results = results.filter((r) => r.status === filter.status);
|
||||
return results.map((r) => this.toPolicy(r));
|
||||
}
|
||||
|
||||
private toPolicy(r: SourceRatePolicyRecord): SourceRatePolicy {
|
||||
return {
|
||||
source_rate_policy_id: r.source_rate_policy_id,
|
||||
tenant_id: r.tenant_id,
|
||||
project_id: r.project_id,
|
||||
source_id: r.source_id,
|
||||
rate_limit_per_minute: r.rate_limit_per_minute,
|
||||
crawl_budget: r.crawl_budget,
|
||||
compliance_notes: r.compliance_notes ?? undefined,
|
||||
domain: r.domain ?? undefined,
|
||||
robots_ok: r.robots_ok ?? undefined,
|
||||
access: (r.access as SourceRatePolicy["access"]) ?? undefined,
|
||||
status: r.status as SourceRatePolicyStatus,
|
||||
correlation_id: r.correlation_id ?? undefined,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at ?? undefined,
|
||||
created_by: r.created_by ?? undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// 0pt.dataset_iceberg_mapping.schema.v1
|
||||
//
|
||||
// Maps a 0pt dataset to the Iceberg table that carries its canonical lakehouse
|
||||
// write path. `iceberg_table_id` is a REFERENCE into ST0RE's table registry
|
||||
// (IcebergTable.tableId, st0re_iceberg_tables) -- it is not a copy of ST0RE's
|
||||
// table metadata. 0pt does not persist table/catalog/snapshot state of its
|
||||
// own; this row exists only to answer "which Iceberg table does this dataset
|
||||
// write to", per docs/10-lakehouse-decision.md and
|
||||
// docs/ICEBERG_WRITE_BOUNDARY.md.
|
||||
|
||||
export type DatasetIcebergMappingStatus = "pending" | "active" | "deprecated" | "archived";
|
||||
|
||||
export interface DatasetIcebergMapping {
|
||||
dataset_iceberg_mapping_id: string;
|
||||
tenant_id: string;
|
||||
project_id: string;
|
||||
dataset_id: string;
|
||||
/** Reference into ST0RE's table registry (IcebergTable.tableId). Not resolved or duplicated locally. */
|
||||
iceberg_table_id: string;
|
||||
/** ST0RE lakehouse namespace the table lives in, carried for convenience/filtering only -- ST0RE remains the source of truth. */
|
||||
namespace?: string;
|
||||
table_name?: string;
|
||||
mapping_status: DatasetIcebergMappingStatus;
|
||||
mapped_by?: string;
|
||||
correlation_id?: string;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface CreateDatasetIcebergMappingInput {
|
||||
dataset_iceberg_mapping_id: string;
|
||||
tenant_id: string;
|
||||
project_id: string;
|
||||
dataset_id: string;
|
||||
iceberg_table_id: string;
|
||||
namespace?: string;
|
||||
table_name?: string;
|
||||
mapping_status?: DatasetIcebergMappingStatus;
|
||||
mapped_by?: string;
|
||||
correlation_id?: string;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// 0pt.source_rate_policy.schema.v1
|
||||
//
|
||||
// The genuine ownership repair this contract encodes: crawl-domain rate
|
||||
// limiting and compliance posture belongs to 0pt, not N0D. N0D's
|
||||
// `ComputePolicy.sourceRatePolicy` field (per-domain crawl rate limits) is
|
||||
// deprecated/removed from N0D's seed data per ADR-0030's audit -- 0pt already
|
||||
// owns the sourcing chain (docs/06-data-engine.md, `sources.yaml`) and is the
|
||||
// only product that should hold this policy.
|
||||
//
|
||||
// Field names reconcile two prior specs written for this same contract:
|
||||
// docs/11-lakehouse-write-plan.md item 5 (domain/requests_per_second/
|
||||
// robots_ok/access) and this pass's task spec (rate_limit_per_minute/
|
||||
// crawl_budget/compliance_notes). Both are kept: the task-spec fields are
|
||||
// primary, the docs/11 fields are additional optional detail for the same
|
||||
// row, not a competing shape.
|
||||
|
||||
export type SourceRatePolicyStatus = "active" | "paused" | "deprecated";
|
||||
|
||||
export interface SourceRatePolicy {
|
||||
source_rate_policy_id: string;
|
||||
tenant_id: string;
|
||||
project_id: string;
|
||||
/** References source.source_id (src/types/source.ts). */
|
||||
source_id: string;
|
||||
rate_limit_per_minute: number;
|
||||
/** Max requests/pages/records this policy permits per crawl run or per day (unit documented in compliance_notes when non-obvious). */
|
||||
crawl_budget: number;
|
||||
/** Free-text compliance rationale: robots.txt posture, licence terms, contact/ToS reference. */
|
||||
compliance_notes?: string;
|
||||
/** Crawl-target domain, e.g. "allabolag.se" -- optional convenience field mirroring docs/06-data-engine.md's sources.yaml rate_limit entries. */
|
||||
domain?: string;
|
||||
/** Whether robots.txt permits the access pattern this policy governs. */
|
||||
robots_ok?: boolean;
|
||||
/** Mirrors dim_source.access (docs/03-data-model.md): api | bulk | authorized_web | licensed. */
|
||||
access?: "api" | "bulk" | "authorized_web" | "licensed";
|
||||
status: SourceRatePolicyStatus;
|
||||
correlation_id?: string;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
export interface CreateSourceRatePolicyInput {
|
||||
source_rate_policy_id: string;
|
||||
tenant_id: string;
|
||||
project_id: string;
|
||||
source_id: string;
|
||||
rate_limit_per_minute: number;
|
||||
crawl_budget: number;
|
||||
compliance_notes?: string;
|
||||
domain?: string;
|
||||
robots_ok?: boolean;
|
||||
access?: "api" | "bulk" | "authorized_web" | "licensed";
|
||||
status?: SourceRatePolicyStatus;
|
||||
correlation_id?: string;
|
||||
created_by?: string;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { DatasetService } from "@/src/services/dataset-service";
|
||||
import { DatasetIcebergMappingService } from "@/src/services/dataset-iceberg-mapping-service";
|
||||
|
||||
describe("DatasetIcebergMappingService", () => {
|
||||
it("createMapping persists a reference into ST0RE's table registry", async () => {
|
||||
const datasetService = new DatasetService();
|
||||
const service = new DatasetIcebergMappingService({ datasetService });
|
||||
await datasetService.registerDataset({
|
||||
dataset_id: "ds_iceberg_map_1",
|
||||
tenant_id: "t1",
|
||||
project_id: "p1",
|
||||
name: "Iceberg Mapping Dataset",
|
||||
dataset_type: "raw",
|
||||
});
|
||||
|
||||
const mapping = await service.createMapping({
|
||||
dataset_iceberg_mapping_id: "dim_test_001",
|
||||
tenant_id: "t1",
|
||||
project_id: "p1",
|
||||
dataset_id: "ds_iceberg_map_1",
|
||||
iceberg_table_id: "st0re_table_abc123",
|
||||
namespace: "0pt_default",
|
||||
table_name: "dim_company_se",
|
||||
});
|
||||
|
||||
expect(mapping.dataset_iceberg_mapping_id).toBe("dim_test_001");
|
||||
expect(mapping.iceberg_table_id).toBe("st0re_table_abc123");
|
||||
expect(mapping.mapping_status).toBe("active");
|
||||
expect(mapping.created_at).toBeDefined();
|
||||
});
|
||||
|
||||
it("createMapping rejects an unknown dataset_id", async () => {
|
||||
const service = new DatasetIcebergMappingService();
|
||||
await expect(
|
||||
service.createMapping({
|
||||
dataset_iceberg_mapping_id: "dim_test_bad",
|
||||
tenant_id: "t1",
|
||||
project_id: "p1",
|
||||
dataset_id: "ds_does_not_exist",
|
||||
iceberg_table_id: "st0re_table_xyz",
|
||||
})
|
||||
).rejects.toThrow("not found");
|
||||
});
|
||||
|
||||
it("createMapping rejects a missing iceberg_table_id", async () => {
|
||||
const datasetService = new DatasetService();
|
||||
const service = new DatasetIcebergMappingService({ datasetService });
|
||||
await datasetService.registerDataset({
|
||||
dataset_id: "ds_iceberg_map_2",
|
||||
tenant_id: "t1",
|
||||
project_id: "p1",
|
||||
name: "No Table Ref",
|
||||
dataset_type: "raw",
|
||||
});
|
||||
await expect(
|
||||
service.createMapping({
|
||||
dataset_iceberg_mapping_id: "dim_test_no_ref",
|
||||
tenant_id: "t1",
|
||||
project_id: "p1",
|
||||
dataset_id: "ds_iceberg_map_2",
|
||||
iceberg_table_id: "",
|
||||
})
|
||||
).rejects.toThrow("iceberg_table_id is required");
|
||||
});
|
||||
|
||||
it("getMappingForDataset returns the active mapping for a dataset", async () => {
|
||||
const datasetService = new DatasetService();
|
||||
const service = new DatasetIcebergMappingService({ datasetService });
|
||||
await datasetService.registerDataset({
|
||||
dataset_id: "ds_iceberg_map_3",
|
||||
tenant_id: "t1",
|
||||
project_id: "p1",
|
||||
name: "Lookup Dataset",
|
||||
dataset_type: "raw",
|
||||
});
|
||||
await service.createMapping({
|
||||
dataset_iceberg_mapping_id: "dim_test_lookup",
|
||||
tenant_id: "t1",
|
||||
project_id: "p1",
|
||||
dataset_id: "ds_iceberg_map_3",
|
||||
iceberg_table_id: "st0re_table_lookup",
|
||||
});
|
||||
const found = await service.getMappingForDataset("ds_iceberg_map_3");
|
||||
expect(found?.iceberg_table_id).toBe("st0re_table_lookup");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import {describe,it,expect} from "vitest";
|
||||
|
||||
describe("0pt Enterprise Data OS Expansion (PROMPT 2)",()=>{
|
||||
it("iceberg_table schema has required fields",()=>{
|
||||
const schema=require("../../packages/schemas/0pt.iceberg_table.schema.json");
|
||||
expect(schema.required).toContain("table_id");
|
||||
expect(schema.required).toContain("table_name");
|
||||
expect(schema.required).toContain("storage_uri");
|
||||
expect(schema.properties.table_format.enum).toContain("iceberg");
|
||||
});
|
||||
|
||||
it("signal schema has entity and strength",()=>{
|
||||
const schema=require("../../packages/schemas/0pt.signal.schema.json");
|
||||
expect(schema.required).toContain("signal_id");
|
||||
expect(schema.required).toContain("signal_type");
|
||||
expect(schema.required).toContain("signal_strength");
|
||||
expect(schema.required).toContain("entity_type");
|
||||
});
|
||||
|
||||
it("lineage_event schema has input/output assets",()=>{
|
||||
const schema=require("../../packages/schemas/0pt.lineage_event.schema.json");
|
||||
expect(schema.required).toContain("input_assets");
|
||||
expect(schema.required).toContain("output_assets");
|
||||
expect(schema.required).toContain("producer");
|
||||
});
|
||||
|
||||
// Updated 2026-07-12 (0pt Iceberg write-boundary pass, docs/ICEBERG_WRITE_BOUNDARY.md):
|
||||
// 0pt.source_rate_policy.schema.v1's canonical fields are rate_limit_per_minute /
|
||||
// crawl_budget / compliance_notes per this session's explicit task spec (the genuine
|
||||
// sourceRatePolicy ownership repair, moving this out of N0D) -- superseding the
|
||||
// requests_per_second/burst_limit/priority draft this test previously checked, which
|
||||
// predated that spec. See src/types/source-rate-policy.ts and
|
||||
// src/services/source-rate-policy-service.ts for the real, tested implementation.
|
||||
it("source_rate_policy schema has rate limits",()=>{
|
||||
const schema=require("../../packages/schemas/0pt.source_rate_policy.schema.json");
|
||||
expect(schema.required).toContain("rate_limit_per_minute");
|
||||
expect(schema.required).toContain("crawl_budget");
|
||||
expect(schema.properties.access.enum).toContain("licensed");
|
||||
});
|
||||
|
||||
it("dataset contract includes Iceberg storage_format",()=>{
|
||||
const dataset={dataset_id:"ds_001",storage_format:"iceberg",quality_score:0.94,pii_level:"medium",status:"active"};
|
||||
expect(dataset.storage_format).toBe("iceberg");
|
||||
expect(dataset.quality_score).toBeGreaterThan(0.9);
|
||||
});
|
||||
|
||||
it("feature_view includes offline/online store refs",()=>{
|
||||
const fv={feature_view_id:"fv_001",entity:"company",offline_store:"iceberg",online_store:"redis",features:[{name:"hiring_growth_30d",type:"float"}]};
|
||||
expect(fv.offline_store).toBe("iceberg");
|
||||
expect(fv.online_store).toBe("redis");
|
||||
expect(fv.features[0].name).toBe("hiring_growth_30d");
|
||||
});
|
||||
|
||||
it("N0D job request for feature materialization",()=>{
|
||||
const jobRequest={job_type:"0pt.feature_materialization_job",capability_id:"0pt.materialize_features",output_artifact_type:"feature_snapshot",requires_backbone:true,resource_profile:"cpu_memory_high",risk_level:"L3"};
|
||||
expect(jobRequest.job_type).toBe("0pt.feature_materialization_job");
|
||||
expect(jobRequest.risk_level).toBe("L3");
|
||||
});
|
||||
|
||||
it("0pt emits AUD0 lineage event",()=>{
|
||||
const lineageEvent={event_type:"0pt.lineage_emitted",app_id:"0pt",capability_id:"0pt.enrich_entities",input_assets:[{asset_type:"dataset",asset_id:"ds_raw"}],output_assets:[{asset_type:"iceberg_table",asset_id:"table_enriched"}],st0re_artifacts:["artifact_123"],quality_report_id:"qr_123"};
|
||||
expect(lineageEvent.event_type).toBe("0pt.lineage_emitted");
|
||||
expect(lineageEvent.output_assets[0].asset_type).toBe("iceberg_table");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { DatasetService } from "@/src/services/dataset-service";
|
||||
import { DatasetIcebergMappingService } from "@/src/services/dataset-iceberg-mapping-service";
|
||||
import { IcebergWriteService } from "@/src/services/iceberg-write-service";
|
||||
|
||||
/**
|
||||
* "Write first test dataset to Iceberg through ST0RE's table registry" proof
|
||||
* (task item 2). This exercises the REAL request/response shapes and control
|
||||
* flow of IcebergWriteService -> St0reLakehouseClient against that client's
|
||||
* deterministic MOCK implementation, the same convention this repo already
|
||||
* uses for every other cross-product boundary (see
|
||||
* tests/p0/cross-product-boundary.test.ts, quality-check-service.ts's own
|
||||
* CL0UD/N0D/ST0RE/AUD0 usage). No ST0RE_URL was available to this pass, so
|
||||
* this is NOT verified against a live, running ST0RE lakehouse API --
|
||||
* src/clients/st0re-lakehouse-client.ts's module doc comment states that
|
||||
* caveat explicitly and it applies here too. What IS real: the table gets
|
||||
* registered, a DatasetIcebergMapping row is created referencing ST0RE's
|
||||
* (mock) tableId, a snapshot gets registered against that table, and all
|
||||
* four AUD0 events fire in the right order with the right payload shape.
|
||||
*/
|
||||
describe("IcebergWriteService", () => {
|
||||
it("mapDatasetToIcebergTable registers a table in ST0RE and records the mapping", async () => {
|
||||
const datasetService = new DatasetService();
|
||||
const mappingService = new DatasetIcebergMappingService({ datasetService });
|
||||
const service = new IcebergWriteService({ datasetService, mappingService });
|
||||
|
||||
await datasetService.registerDataset({
|
||||
dataset_id: "ds_first_iceberg_write",
|
||||
tenant_id: "t1",
|
||||
project_id: "p1",
|
||||
name: "First Iceberg Pilot Dataset",
|
||||
dataset_type: "enriched",
|
||||
});
|
||||
|
||||
const result = await service.mapDatasetToIcebergTable({
|
||||
tenant_id: "t1",
|
||||
project_id: "p1",
|
||||
dataset_id: "ds_first_iceberg_write",
|
||||
dataset_iceberg_mapping_id: "dim_first_write",
|
||||
namespace: "0pt_pilot",
|
||||
table_name: "dim_company_se_pilot",
|
||||
storage_uri: "s3://siax-p0/lakehouse/0pt_pilot/dim_company_se_pilot",
|
||||
catalog_ref: "0pt_pilot.dim_company_se_pilot",
|
||||
actor_id: "user_123",
|
||||
correlation_id: "corr_first_write",
|
||||
});
|
||||
|
||||
expect(result.iceberg_table_id).toContain("mock_table_");
|
||||
expect(result.policy_decision_id).toBeDefined();
|
||||
expect(result.mapping.dataset_id).toBe("ds_first_iceberg_write");
|
||||
expect(result.mapping.iceberg_table_id).toBe(result.iceberg_table_id);
|
||||
expect(result.mapping.mapping_status).toBe("active");
|
||||
|
||||
const lookedUp = await mappingService.getMappingForDataset("ds_first_iceberg_write");
|
||||
expect(lookedUp?.dataset_iceberg_mapping_id).toBe("dim_first_write");
|
||||
});
|
||||
|
||||
it("requestIcebergWrite registers a snapshot through ST0RE for an existing mapping", async () => {
|
||||
const datasetService = new DatasetService();
|
||||
const mappingService = new DatasetIcebergMappingService({ datasetService });
|
||||
const service = new IcebergWriteService({ datasetService, mappingService });
|
||||
|
||||
await datasetService.registerDataset({
|
||||
dataset_id: "ds_snapshot_write",
|
||||
tenant_id: "t1",
|
||||
project_id: "p1",
|
||||
name: "Snapshot Write Dataset",
|
||||
dataset_type: "enriched",
|
||||
});
|
||||
const mapped = await service.mapDatasetToIcebergTable({
|
||||
tenant_id: "t1",
|
||||
project_id: "p1",
|
||||
dataset_id: "ds_snapshot_write",
|
||||
dataset_iceberg_mapping_id: "dim_snapshot_write",
|
||||
namespace: "0pt_pilot",
|
||||
table_name: "dim_company_se_snapshot",
|
||||
storage_uri: "s3://siax-p0/lakehouse/0pt_pilot/dim_company_se_snapshot",
|
||||
catalog_ref: "0pt_pilot.dim_company_se_snapshot",
|
||||
actor_id: "user_123",
|
||||
correlation_id: "corr_snapshot_setup",
|
||||
});
|
||||
|
||||
const result = await service.requestIcebergWrite({
|
||||
dataset_iceberg_mapping_id: mapped.mapping.dataset_iceberg_mapping_id,
|
||||
actor_id: "user_123",
|
||||
correlation_id: "corr_snapshot_write",
|
||||
schema_version: "1",
|
||||
manifest_uri: "s3://siax-p0/lakehouse/0pt_pilot/dim_company_se_snapshot/metadata/manifest-1.avro",
|
||||
record_count: 1000,
|
||||
size_bytes: 204800,
|
||||
});
|
||||
|
||||
expect(result.snapshot_id).toContain("mock_snapshot_");
|
||||
expect(result.iceberg_table_id).toBe(mapped.iceberg_table_id);
|
||||
expect(result.n0d_job_id).toBeUndefined(); // not a heavy write
|
||||
});
|
||||
|
||||
it("requestIcebergWrite routes heavy writes through an N0D job request, not in-process execution", async () => {
|
||||
const datasetService = new DatasetService();
|
||||
const mappingService = new DatasetIcebergMappingService({ datasetService });
|
||||
const service = new IcebergWriteService({ datasetService, mappingService });
|
||||
|
||||
await datasetService.registerDataset({
|
||||
dataset_id: "ds_heavy_write",
|
||||
tenant_id: "t1",
|
||||
project_id: "p1",
|
||||
name: "Heavy Backfill Dataset",
|
||||
dataset_type: "raw",
|
||||
});
|
||||
const mapped = await service.mapDatasetToIcebergTable({
|
||||
tenant_id: "t1",
|
||||
project_id: "p1",
|
||||
dataset_id: "ds_heavy_write",
|
||||
dataset_iceberg_mapping_id: "dim_heavy_write",
|
||||
namespace: "0pt_pilot",
|
||||
table_name: "fact_financials_backfill",
|
||||
storage_uri: "s3://siax-p0/lakehouse/0pt_pilot/fact_financials_backfill",
|
||||
catalog_ref: "0pt_pilot.fact_financials_backfill",
|
||||
actor_id: "user_123",
|
||||
correlation_id: "corr_heavy_setup",
|
||||
});
|
||||
|
||||
const result = await service.requestIcebergWrite({
|
||||
dataset_iceberg_mapping_id: mapped.mapping.dataset_iceberg_mapping_id,
|
||||
actor_id: "user_123",
|
||||
correlation_id: "corr_heavy_write",
|
||||
schema_version: "1",
|
||||
manifest_uri: "s3://siax-p0/lakehouse/0pt_pilot/fact_financials_backfill/metadata/manifest-backfill.avro",
|
||||
heavy: true,
|
||||
});
|
||||
|
||||
expect(result.n0d_job_id).toBeDefined();
|
||||
expect(result.n0d_job_id).toContain("mock_job_");
|
||||
});
|
||||
|
||||
it("requestIcebergWrite rejects writes against a non-active mapping", async () => {
|
||||
const datasetService = new DatasetService();
|
||||
const mappingService = new DatasetIcebergMappingService({ datasetService });
|
||||
const service = new IcebergWriteService({ datasetService, mappingService });
|
||||
|
||||
await datasetService.registerDataset({
|
||||
dataset_id: "ds_deprecated_mapping",
|
||||
tenant_id: "t1",
|
||||
project_id: "p1",
|
||||
name: "Deprecated Mapping Dataset",
|
||||
dataset_type: "raw",
|
||||
});
|
||||
await mappingService.createMapping({
|
||||
dataset_iceberg_mapping_id: "dim_deprecated",
|
||||
tenant_id: "t1",
|
||||
project_id: "p1",
|
||||
dataset_id: "ds_deprecated_mapping",
|
||||
iceberg_table_id: "st0re_table_deprecated",
|
||||
mapping_status: "deprecated",
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.requestIcebergWrite({
|
||||
dataset_iceberg_mapping_id: "dim_deprecated",
|
||||
actor_id: "user_123",
|
||||
correlation_id: "corr_deprecated_write",
|
||||
schema_version: "1",
|
||||
manifest_uri: "s3://siax-p0/lakehouse/deprecated/manifest.avro",
|
||||
})
|
||||
).rejects.toThrow("is not active");
|
||||
});
|
||||
|
||||
// TODO(future session): once ST0RE_URL points at a live, reachable ST0RE
|
||||
// deployment (sax3l/st0re, /v1/st0re/lakehouse/*, port 8090 per that repo's
|
||||
// STATUS.md), add a live-integration test here that constructs
|
||||
// IcebergWriteService with { st0reBaseUrl: process.env.ST0RE_URL } and
|
||||
// asserts the same flow against the real HTTP API. Not built in this pass
|
||||
// -- no reachable ST0RE endpoint was available to verify against. See
|
||||
// src/clients/st0re-lakehouse-client.ts's module doc comment and
|
||||
// docs/ICEBERG_WRITE_BOUNDARY.md "Verification status" section.
|
||||
it.todo("mapDatasetToIcebergTable against a LIVE ST0RE deployment (ST0RE_URL) -- not live-verified in this pass");
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { SourceService } from "@/src/services/source-service";
|
||||
import { SourceRatePolicyService } from "@/src/services/source-rate-policy-service";
|
||||
|
||||
describe("SourceRatePolicyService", () => {
|
||||
it("createSourceRatePolicy persists rate limit + crawl budget + compliance notes", async () => {
|
||||
const sourceService = new SourceService();
|
||||
const service = new SourceRatePolicyService({ sourceService });
|
||||
await sourceService.createSource({
|
||||
source_id: "src_rate_pol_1",
|
||||
tenant_id: "t1",
|
||||
project_id: "p1",
|
||||
source_type: "web",
|
||||
name: "allabolag.se",
|
||||
});
|
||||
|
||||
const policy = await service.createSourceRatePolicy({
|
||||
source_rate_policy_id: "srp_test_001",
|
||||
tenant_id: "t1",
|
||||
project_id: "p1",
|
||||
source_id: "src_rate_pol_1",
|
||||
rate_limit_per_minute: 60,
|
||||
crawl_budget: 5000,
|
||||
compliance_notes: "robots.txt allows /bolag/*; authorized_web tier per docs/06-data-engine.md",
|
||||
domain: "allabolag.se",
|
||||
robots_ok: true,
|
||||
access: "authorized_web",
|
||||
});
|
||||
|
||||
expect(policy.source_rate_policy_id).toBe("srp_test_001");
|
||||
expect(policy.rate_limit_per_minute).toBe(60);
|
||||
expect(policy.crawl_budget).toBe(5000);
|
||||
expect(policy.compliance_notes).toContain("robots.txt");
|
||||
expect(policy.status).toBe("active");
|
||||
});
|
||||
|
||||
it("createSourceRatePolicy rejects an unknown source_id", async () => {
|
||||
const service = new SourceRatePolicyService();
|
||||
await expect(
|
||||
service.createSourceRatePolicy({
|
||||
source_rate_policy_id: "srp_test_bad",
|
||||
tenant_id: "t1",
|
||||
project_id: "p1",
|
||||
source_id: "src_does_not_exist",
|
||||
rate_limit_per_minute: 10,
|
||||
crawl_budget: 100,
|
||||
})
|
||||
).rejects.toThrow("not found");
|
||||
});
|
||||
|
||||
it("createSourceRatePolicy rejects a non-positive rate_limit_per_minute", async () => {
|
||||
const sourceService = new SourceService();
|
||||
const service = new SourceRatePolicyService({ sourceService });
|
||||
await sourceService.createSource({
|
||||
source_id: "src_rate_pol_2",
|
||||
tenant_id: "t1",
|
||||
project_id: "p1",
|
||||
source_type: "api",
|
||||
name: "ratsit.se",
|
||||
});
|
||||
await expect(
|
||||
service.createSourceRatePolicy({
|
||||
source_rate_policy_id: "srp_test_zero",
|
||||
tenant_id: "t1",
|
||||
project_id: "p1",
|
||||
source_id: "src_rate_pol_2",
|
||||
rate_limit_per_minute: 0,
|
||||
crawl_budget: 100,
|
||||
})
|
||||
).rejects.toThrow("rate_limit_per_minute must be > 0");
|
||||
});
|
||||
|
||||
it("listSourceRatePolicies filters by source_id", async () => {
|
||||
const sourceService = new SourceService();
|
||||
const service = new SourceRatePolicyService({ sourceService });
|
||||
await sourceService.createSource({ source_id: "src_rate_pol_3", tenant_id: "t1", project_id: "p1", source_type: "web", name: "eniro.se" });
|
||||
await sourceService.createSource({ source_id: "src_rate_pol_4", tenant_id: "t1", project_id: "p1", source_type: "web", name: "merinfo.se" });
|
||||
await service.createSourceRatePolicy({ source_rate_policy_id: "srp_list_1", tenant_id: "t1", project_id: "p1", source_id: "src_rate_pol_3", rate_limit_per_minute: 30, crawl_budget: 1000 });
|
||||
await service.createSourceRatePolicy({ source_rate_policy_id: "srp_list_2", tenant_id: "t1", project_id: "p1", source_id: "src_rate_pol_4", rate_limit_per_minute: 30, crawl_budget: 1000 });
|
||||
const results = await service.listSourceRatePolicies({ source_id: "src_rate_pol_3" });
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].source_rate_policy_id).toBe("srp_list_1");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user