feat(insights): add parent resource pointer

Eldad's review comment: insights about nested resources need a pointer
to the containing parent (the file-in-bucket pattern). Add three
optional fields:

- parentResourceType (plural noun, e.g. `tables`, `collections`)
- parentResourceId
- parentResourceInternalId

so an insight whose `resourceType=indexes` / `resourceId=_idx_status`
can also carry `parentResourceType=tables` / `parentResourceId=orders`
to identify the table that owns the index. All three are nullable for
top-level resources (e.g. a project-wide audit finding).

Schema, response model, manager Create endpoint, and the listInsights
query validator (parent fields are filterable). New compound index
`_key_project_parent_resource(projectInternalId, parentResourceType,
parentResourceId, $sequence)` to support the parent lookup pattern
the console will use ("show all insights for table X").

E2E factory generates a parent by default (engine-aware:
tables for tablesDB, collections for the others). New
testCreateWithoutParentResource exercises the top-level case;
testList gains a parent-resource filter assertion; testUpdate's
preserved-fields check picks up the new attributes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jake Barnby
2026-05-06 17:25:34 +12:00
co-authored by Claude Opus 4.7
parent 4fc3e9c386
commit 38efdf18e2
5 changed files with 122 additions and 9 deletions
+43
View File
@@ -2191,6 +2191,42 @@ $platformCollections = [
'array' => false,
'filters' => [],
],
[
// Plural noun for the parent (containing) resource. Optional.
// e.g. an insight about a column index → resourceType=indexes,
// parentResourceType=tables. Mirrors the file-in-bucket pointer.
'$id' => ID::custom('parentResourceType'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 64,
'signed' => true,
'required' => false,
'default' => '',
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('parentResourceId'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => Database::LENGTH_KEY,
'signed' => true,
'required' => false,
'default' => '',
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('parentResourceInternalId'),
'type' => Database::VAR_ID,
'format' => '',
'size' => 0,
'signed' => true,
'required' => false,
'default' => '',
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('title'),
'type' => Database::VAR_STRING,
@@ -2291,6 +2327,13 @@ $platformCollections = [
'lengths' => [Database::LENGTH_KEY, 64, Database::LENGTH_KEY],
'orders' => [],
],
[
'$id' => ID::custom('_key_project_parent_resource'),
'type' => Database::INDEX_KEY,
'attributes' => ['projectInternalId', 'parentResourceType', 'parentResourceId', '$sequence'],
'lengths' => [Database::LENGTH_KEY, 64, Database::LENGTH_KEY],
'orders' => [],
],
[
'$id' => ID::custom('_key_project_type'),
'type' => Database::INDEX_KEY,
@@ -79,6 +79,9 @@ class Create extends Action
->param('resourceType', '', new Text(64), 'Plural resource type the insight is about, e.g. `databases`, `sites`, `functions`.')
->param('resourceId', '', new Text(36), 'ID of the resource the insight is about.')
->param('resourceInternalId', '', new Text(36), 'Internal ID of the resource the insight is about.', true)
->param('parentResourceType', '', new Text(64), 'Plural noun for the parent (containing) resource, e.g. `tables` for an insight about a column index. Optional.', true)
->param('parentResourceId', '', new Text(36), 'ID of the parent resource.', true)
->param('parentResourceInternalId', '', new Text(36), 'Internal ID of the parent resource.', true)
->param('title', '', new Text(256), 'Short, human-readable title.')
->param('summary', '', new Text(4096, 0), 'Markdown summary describing the insight.', true)
->param('payload', null, new Nullable(new JSON()), 'Type-specific structured payload.', true)
@@ -99,6 +102,9 @@ class Create extends Action
string $resourceType,
string $resourceId,
string $resourceInternalId,
string $parentResourceType,
string $parentResourceId,
string $parentResourceInternalId,
string $title,
string $summary,
?array $payload,
@@ -155,6 +161,9 @@ class Create extends Action
'resourceType' => $resourceType,
'resourceId' => $resourceId,
'resourceInternalId' => $resourceInternalId,
'parentResourceType' => $parentResourceType,
'parentResourceId' => $parentResourceId,
'parentResourceInternalId' => $parentResourceInternalId,
'title' => $title,
'summary' => $summary,
'payload' => $payload,
@@ -11,6 +11,8 @@ class Insights extends Base
'reportId',
'resourceType',
'resourceId',
'parentResourceType',
'parentResourceId',
'analyzedAt',
'dismissedAt',
'dismissedBy',
@@ -77,6 +77,24 @@ class Insight extends Model
'default' => '',
'example' => '5e5ea5c16897e',
])
->addRule('parentResourceType', [
'type' => self::TYPE_STRING,
'description' => 'Plural noun for the parent resource that contains the insight\'s resource, e.g. an insight about a column index on a table → resourceType=indexes, parentResourceType=tables. Empty when the resource has no parent.',
'default' => '',
'example' => 'tables',
])
->addRule('parentResourceId', [
'type' => self::TYPE_STRING,
'description' => 'ID of the parent resource. Empty when the resource has no parent.',
'default' => '',
'example' => 'orders',
])
->addRule('parentResourceInternalId', [
'type' => self::TYPE_STRING,
'description' => 'Internal ID of the parent resource. Empty when the resource has no parent.',
'default' => '',
'example' => '5e5ea5c16897e',
])
->addRule('title', [
'type' => self::TYPE_STRING,
'description' => 'Insight title.',
+50 -9
View File
@@ -147,20 +147,25 @@ trait InsightsBase
default => throw new \InvalidArgumentException("Unknown engine: {$engine}"),
};
$resourceType = match ($engine) {
'databases' => 'databases',
// The insight is *about* a missing index, contained within a table/collection.
// resourceType=indexes points at the index that should exist; the parent
// points at the table/collection that owns it.
$parentResourceType = match ($engine) {
'databases' => 'collections',
'tablesDB' => 'tables',
'documentsDB' => 'collections',
'vectorsDB' => 'collections',
default => 'databases',
default => 'collections',
};
$body = [
'insightId' => $insightId ?? ID::unique(),
'type' => $type,
'severity' => 'warning',
'resourceType' => $resourceType,
'resourceId' => 'main',
'resourceType' => 'indexes',
'resourceId' => '_idx_status',
'parentResourceType' => $parentResourceType,
'parentResourceId' => 'orders',
'title' => 'Missing index on collection orders',
'summary' => 'Queries against `orders.status` are scanning the full collection.',
'payload' => ['databaseId' => 'main', 'engine' => $engine],
@@ -338,8 +343,10 @@ trait InsightsBase
$this->assertSame('tablesDBIndex', $insight['body']['type']);
$this->assertSame('warning', $insight['body']['severity']);
$this->assertSame('active', $insight['body']['status']);
$this->assertSame('tables', $insight['body']['resourceType']);
$this->assertSame('main', $insight['body']['resourceId']);
$this->assertSame('indexes', $insight['body']['resourceType']);
$this->assertSame('_idx_status', $insight['body']['resourceId']);
$this->assertSame('tables', $insight['body']['parentResourceType']);
$this->assertSame('orders', $insight['body']['parentResourceId']);
$this->assertSame('Missing index on collection orders', $insight['body']['title']);
$this->assertCount(1, $insight['body']['ctas']);
$this->assertSame('createIndex', $insight['body']['ctas'][0]['id']);
@@ -397,6 +404,26 @@ trait InsightsBase
$this->deleteInsight($insightId);
}
public function testCreateWithoutParentResource(): void
{
// Top-level resource (no parent) — e.g. a project-wide audit finding.
$insightId = ID::unique();
$body = $this->sampleInsight($insightId);
unset($body['parentResourceType'], $body['parentResourceId']);
$body['resourceType'] = 'projects';
$body['resourceId'] = $this->getProject()['$id'];
$insight = $this->createInsight($body);
$this->assertSame(201, $insight['headers']['status-code']);
$this->assertSame('projects', $insight['body']['resourceType']);
$this->assertEmpty($insight['body']['parentResourceType']);
$this->assertEmpty($insight['body']['parentResourceId']);
$this->assertEmpty($insight['body']['parentResourceInternalId']);
$this->deleteInsight($insightId);
}
public function testCreateRejectsInvalidType(): void
{
$insight = $this->createInsight([
@@ -563,11 +590,23 @@ trait InsightsBase
$this->assertNotEmpty($list['body']['insights']);
$byResourceType = $this->listInsights([
'queries' => ['equal("resourceType", "tables")'],
'queries' => ['equal("resourceType", "indexes")'],
]);
$this->assertSame(200, $byResourceType['headers']['status-code']);
foreach ($byResourceType['body']['insights'] as $insight) {
$this->assertSame('tables', $insight['resourceType']);
$this->assertSame('indexes', $insight['resourceType']);
}
$byParentResource = $this->listInsights([
'queries' => [
'equal("parentResourceType", "tables")',
'equal("parentResourceId", "orders")',
],
]);
$this->assertSame(200, $byParentResource['headers']['status-code']);
foreach ($byParentResource['body']['insights'] as $insight) {
$this->assertSame('tables', $insight['parentResourceType']);
$this->assertSame('orders', $insight['parentResourceId']);
}
$byStatus = $this->listInsights([
@@ -676,6 +715,8 @@ trait InsightsBase
$this->assertSame($original['type'], $updated['body']['type']);
$this->assertSame($original['resourceType'], $updated['body']['resourceType']);
$this->assertSame($original['resourceId'], $updated['body']['resourceId']);
$this->assertSame($original['parentResourceType'], $updated['body']['parentResourceType']);
$this->assertSame($original['parentResourceId'], $updated['body']['parentResourceId']);
$this->assertSame($original['reportId'], $updated['body']['reportId']);
$this->assertSame($original['ctas'], $updated['body']['ctas']);
$this->assertSame($original['payload'], $updated['body']['payload']);