@rebasepro/server-postgres 0.10.1-canary.d8d45b2 → 0.10.1-canary.ed78a2c
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/schema-version.d.ts +106 -0
- package/dist/collections/validate-relations.d.ts +53 -0
- package/dist/data-transformer.d.ts +3 -3
- package/dist/{ensure-collection-tables-CNlIONzj.js → ensure-collection-tables-DGMYK0fr.js} +12 -12
- package/dist/ensure-collection-tables-DGMYK0fr.js.map +1 -0
- package/dist/index.es.js +913 -391
- package/dist/index.es.js.map +1 -1
- package/dist/services/FetchService.d.ts +21 -8
- package/dist/services/PersistService.d.ts +12 -0
- package/dist/services/RelationService.d.ts +39 -8
- package/dist/services/cdc/junction-tables.d.ts +38 -0
- package/dist/services/nested-path.d.ts +59 -0
- package/dist/services/realtimeService.d.ts +19 -0
- package/dist/services/row-pipeline.d.ts +2 -2
- package/dist/{src-DmsRg8MR.js → src-3VmUJ8Xn.js} +214 -276
- package/dist/src-3VmUJ8Xn.js.map +1 -0
- package/dist/{src-B0v4IKaI.js → src-D5xBTl32.js} +19 -2
- package/dist/src-D5xBTl32.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +71 -18
- package/package.json +8 -9
- package/src/PostgresBootstrapper.ts +15 -2
- package/src/auth/ensure-tables.ts +23 -0
- package/src/auth/schema-version.ts +260 -0
- package/src/cli-errors.ts +1 -1
- package/src/cli-helpers.ts +4 -3
- package/src/collections/PostgresCollectionRegistry.ts +9 -4
- package/src/collections/buildRegistry.ts +7 -0
- package/src/collections/validate-relations.ts +280 -0
- package/src/data-transformer.ts +28 -38
- package/src/schema/doctor.ts +14 -14
- package/src/schema/generate-drizzle-schema-logic.ts +62 -110
- package/src/schema/generate-postgres-ddl-logic.ts +28 -21
- package/src/schema/introspect-db-inference.ts +13 -13
- package/src/schema/introspect-db-logic.ts +25 -29
- package/src/services/FetchService.ts +116 -126
- package/src/services/PersistService.ts +126 -88
- package/src/services/RelationService.ts +157 -86
- package/src/services/cdc/junction-tables.ts +91 -0
- package/src/services/nested-path.ts +145 -0
- package/src/services/realtimeService.ts +60 -0
- package/src/services/row-pipeline.ts +5 -6
- package/src/utils/drizzle-conditions.ts +268 -330
- package/dist/ensure-collection-tables-CNlIONzj.js.map +0 -1
- package/dist/src-B0v4IKaI.js.map +0 -1
- package/dist/src-DmsRg8MR.js.map +0 -1
|
@@ -5,6 +5,7 @@ import type { VectorSearchParams } from "@rebasepro/types";
|
|
|
5
5
|
import { RelationService } from "./RelationService";
|
|
6
6
|
import { DrizzleClient } from "../interfaces";
|
|
7
7
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
8
|
+
import { type NestedPathHop } from "./nested-path";
|
|
8
9
|
/**
|
|
9
10
|
* Service for handling all row read operations.
|
|
10
11
|
* Handles fetching, searching, counting, and filtering rows.
|
|
@@ -65,6 +66,22 @@ export declare class FetchService {
|
|
|
65
66
|
* Extract cursor pagination conditions from startAfter options.
|
|
66
67
|
*/
|
|
67
68
|
private buildCursorConditions;
|
|
69
|
+
/**
|
|
70
|
+
* Compile "rows reachable from this parent" into a `WHERE` condition on the
|
|
71
|
+
* target table, so a nested listing can run as an ordinary collection query.
|
|
72
|
+
*/
|
|
73
|
+
private buildRelationScope;
|
|
74
|
+
/**
|
|
75
|
+
* Whether `id` is actually reachable at `collectionPath`.
|
|
76
|
+
*
|
|
77
|
+
* Trivially true for a root path. For a nested one it is a real question:
|
|
78
|
+
* the path resolves to the target collection, and matching on the primary
|
|
79
|
+
* key alone made the parent segment decorative — `authors/1/posts/43`
|
|
80
|
+
* returned post 43 whoever wrote it, and the REST layer's delete then
|
|
81
|
+
* deleted it. A row that is not under this parent is reported as absent,
|
|
82
|
+
* which is what a caller addressing it through the parent should see.
|
|
83
|
+
*/
|
|
84
|
+
private isAddressableUnder;
|
|
68
85
|
/**
|
|
69
86
|
* Fetch a single row by ID
|
|
70
87
|
*/
|
|
@@ -83,6 +100,8 @@ export declare class FetchService {
|
|
|
83
100
|
databaseId?: string;
|
|
84
101
|
vectorSearch?: VectorSearchParams;
|
|
85
102
|
logical?: LogicalCondition;
|
|
103
|
+
/** Narrow to the rows reachable from a parent through a relation. */
|
|
104
|
+
relatedTo?: NestedPathHop;
|
|
86
105
|
}): Promise<Record<string, unknown>[]>;
|
|
87
106
|
/**
|
|
88
107
|
* Fallback path used when db.query is unavailable.
|
|
@@ -118,10 +137,6 @@ export declare class FetchService {
|
|
|
118
137
|
limit?: number;
|
|
119
138
|
databaseId?: string;
|
|
120
139
|
}): Promise<Record<string, unknown>[]>;
|
|
121
|
-
/**
|
|
122
|
-
* Fetch collection from multi-segment path
|
|
123
|
-
*/
|
|
124
|
-
private fetchCollectionFromPath;
|
|
125
140
|
/**
|
|
126
141
|
* Count rows in a collection
|
|
127
142
|
*/
|
|
@@ -130,10 +145,6 @@ export declare class FetchService {
|
|
|
130
145
|
searchString?: string;
|
|
131
146
|
databaseId?: string;
|
|
132
147
|
}): Promise<number>;
|
|
133
|
-
/**
|
|
134
|
-
* Count rows from multi-segment path
|
|
135
|
-
*/
|
|
136
|
-
private countEntitiesFromPath;
|
|
137
148
|
/**
|
|
138
149
|
* Check if a field value is unique
|
|
139
150
|
*/
|
|
@@ -160,6 +171,8 @@ export declare class FetchService {
|
|
|
160
171
|
searchString?: string;
|
|
161
172
|
databaseId?: string;
|
|
162
173
|
vectorSearch?: VectorSearchParams;
|
|
174
|
+
/** Narrow to the rows reachable from a parent through a relation. */
|
|
175
|
+
relatedTo?: NestedPathHop;
|
|
163
176
|
}, include?: string[]): Promise<Record<string, unknown>[]>;
|
|
164
177
|
/**
|
|
165
178
|
* Fetch a single row with optional relation includes for REST API.
|
|
@@ -38,6 +38,18 @@ export declare class PersistService {
|
|
|
38
38
|
* Delete all rows from a collection
|
|
39
39
|
*/
|
|
40
40
|
deleteAll(collectionPath: string, _databaseId?: string): Promise<void>;
|
|
41
|
+
/**
|
|
42
|
+
* The column on the *target* table that records the parent, for a create
|
|
43
|
+
* under a nested one-to-many path.
|
|
44
|
+
*
|
|
45
|
+
* Returns `undefined` when the link is not a column at all (a multi-hop
|
|
46
|
+
* `joinPath`), so the caller writes the row without stamping anything.
|
|
47
|
+
*
|
|
48
|
+
* `relation.localKey` is deliberately not consulted: it names a column on
|
|
49
|
+
* the *source* table. Falling back to it here — which is what this used to
|
|
50
|
+
* do, and first — stamped the parent's own foreign key onto the child row.
|
|
51
|
+
*/
|
|
52
|
+
private resolveParentForeignKeyColumn;
|
|
41
53
|
/**
|
|
42
54
|
* Save an row (create or update)
|
|
43
55
|
*
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { DrizzleClient } from "../interfaces";
|
|
2
|
-
import { CollectionConfig, FilterValues,
|
|
2
|
+
import { CollectionConfig, FilterValues, ResolvedRelation, ResolvedManyToMany } from "@rebasepro/types";
|
|
3
|
+
import { type ResolvedVia } from "@rebasepro/types";
|
|
3
4
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
5
|
+
import type { NestedPathHop } from "./nested-path";
|
|
4
6
|
/**
|
|
5
7
|
* Service for handling all relation-related operations.
|
|
6
8
|
* Handles fetching, updating, and managing row relations.
|
|
@@ -69,7 +71,7 @@ export declare class RelationService {
|
|
|
69
71
|
/**
|
|
70
72
|
* Fetch rows using join paths for complex relations
|
|
71
73
|
*/
|
|
72
|
-
fetchEntitiesUsingJoins<M extends Record<string, unknown>>(parentCollection: CollectionConfig, parentId: string | number, relation:
|
|
74
|
+
fetchEntitiesUsingJoins<M extends Record<string, unknown>>(parentCollection: CollectionConfig, parentId: string | number, relation: ResolvedRelation, options?: {
|
|
73
75
|
filter?: FilterValues<Extract<keyof M, string>>;
|
|
74
76
|
orderBy?: string;
|
|
75
77
|
order?: "desc" | "asc";
|
|
@@ -85,16 +87,45 @@ export declare class RelationService {
|
|
|
85
87
|
filter?: FilterValues<Extract<keyof M, string>>;
|
|
86
88
|
databaseId?: string;
|
|
87
89
|
}): Promise<number>;
|
|
90
|
+
/**
|
|
91
|
+
* Count the target rows a parent reaches through `relation`, narrowed by
|
|
92
|
+
* `additionalFilters` (conditions on the target table).
|
|
93
|
+
*
|
|
94
|
+
* Shared by the public count and by {@link isRelated}, so "how many children
|
|
95
|
+
* does this parent have" and "is this row one of them" are answered by the
|
|
96
|
+
* same join — a membership test that reconstructed the join separately would
|
|
97
|
+
* be free to disagree with the listing it is supposed to gate.
|
|
98
|
+
*/
|
|
99
|
+
private countRelatedRows;
|
|
100
|
+
/**
|
|
101
|
+
* Whether `targetId` is actually reachable from the parent named in `hop`.
|
|
102
|
+
*
|
|
103
|
+
* A nested address like `authors/1/posts/43` used to resolve to the target
|
|
104
|
+
* collection and then match on the primary key alone, so the parent segment
|
|
105
|
+
* decided nothing: the row came back, and was updated or deleted, whoever it
|
|
106
|
+
* belonged to. Reads, updates and deletes now all gate on this.
|
|
107
|
+
*/
|
|
108
|
+
isRelated(hop: NestedPathHop, targetId: string | number): Promise<boolean>;
|
|
109
|
+
/**
|
|
110
|
+
* Remove the junction row linking a parent to `targetId`, leaving the target
|
|
111
|
+
* row itself alone.
|
|
112
|
+
*
|
|
113
|
+
* This is what `DELETE authors/1/tags/5` has to mean for a many-to-many: the
|
|
114
|
+
* target is shared, so deleting the row would remove the tag from every other
|
|
115
|
+
* post that uses it. It used to do exactly that — resolve the path to the
|
|
116
|
+
* `tags` table and delete by primary key.
|
|
117
|
+
*/
|
|
118
|
+
unlinkRelatedEntity(tx: DrizzleClient, hop: NestedPathHop, targetId: string | number): Promise<void>;
|
|
88
119
|
/**
|
|
89
120
|
* Batch fetch related rows for multiple parent rows to avoid N+1 queries
|
|
90
121
|
*/
|
|
91
|
-
batchFetchRelatedEntities(parentCollectionPath: string, parentIds: (string | number)[], _relationKey: string, relation:
|
|
122
|
+
batchFetchRelatedEntities(parentCollectionPath: string, parentIds: (string | number)[], _relationKey: string, relation: ResolvedRelation): Promise<Map<string, RelatedRow<Record<string, unknown>>>>;
|
|
92
123
|
/**
|
|
93
124
|
* Batch fetch many-cardinality related rows for multiple parent rows.
|
|
94
125
|
* Returns a Map<parentId, RelatedRow[]> instead of Map<parentId, RelatedRow>.
|
|
95
126
|
* Uses a single SQL query with IN clause to avoid N+1.
|
|
96
127
|
*/
|
|
97
|
-
batchFetchRelatedEntitiesMany(parentCollectionPath: string, parentIds: (string | number)[], _relationKey: string, relation:
|
|
128
|
+
batchFetchRelatedEntitiesMany(parentCollectionPath: string, parentIds: (string | number)[], _relationKey: string, relation: ResolvedRelation): Promise<Map<string, RelatedRow<Record<string, unknown>>[]>>;
|
|
98
129
|
/**
|
|
99
130
|
* Update many-to-many and junction relations
|
|
100
131
|
*/
|
|
@@ -104,7 +135,7 @@ export declare class RelationService {
|
|
|
104
135
|
*/
|
|
105
136
|
updateInverseRelations(tx: DrizzleClient, sourceCollection: CollectionConfig, sourceEntityId: string | number, inverseRelationUpdates: Array<{
|
|
106
137
|
relationKey: string;
|
|
107
|
-
relation:
|
|
138
|
+
relation: ResolvedRelation;
|
|
108
139
|
newValue: unknown;
|
|
109
140
|
}>): Promise<void>;
|
|
110
141
|
/**
|
|
@@ -120,13 +151,13 @@ export declare class RelationService {
|
|
|
120
151
|
*/
|
|
121
152
|
updateJoinPathOneToOneRelations(tx: DrizzleClient, parentCollection: CollectionConfig, parentId: string | number, updates: Array<{
|
|
122
153
|
relationKey: string;
|
|
123
|
-
relation:
|
|
154
|
+
relation: ResolvedVia;
|
|
124
155
|
newTargetId: string | number | null;
|
|
125
156
|
}>): Promise<void>;
|
|
126
157
|
/**
|
|
127
158
|
* Resolve joinPath write mapping for one-to-one relations
|
|
128
159
|
*/
|
|
129
|
-
resolveJoinPathWriteMapping(parentCollection: CollectionConfig, relation:
|
|
160
|
+
resolveJoinPathWriteMapping(parentCollection: CollectionConfig, relation: ResolvedVia): {
|
|
130
161
|
targetFKColName: string;
|
|
131
162
|
parentSourceColName: string;
|
|
132
163
|
};
|
|
@@ -136,7 +167,7 @@ export declare class RelationService {
|
|
|
136
167
|
handleJunctionTableCreation(tx: DrizzleClient, newEntityId: string | number, junctionTableInfo: {
|
|
137
168
|
parentCollection: CollectionConfig;
|
|
138
169
|
parentId: string | number;
|
|
139
|
-
relation:
|
|
170
|
+
relation: ResolvedManyToMany;
|
|
140
171
|
relationKey: string;
|
|
141
172
|
}): Promise<void>;
|
|
142
173
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { CollectionConfig } from "@rebasepro/types";
|
|
2
|
+
import { PostgresCollectionRegistry } from "../../collections/PostgresCollectionRegistry";
|
|
3
|
+
/**
|
|
4
|
+
* One end of a many-to-many, as seen from the junction table.
|
|
5
|
+
*
|
|
6
|
+
* A junction table is not a collection, so nothing in the registry maps it to
|
|
7
|
+
* one — which is why a change to it was invisible to change capture. But its
|
|
8
|
+
* rows are exactly the contents of a parent's child list, so a write to it is a
|
|
9
|
+
* change to `<parentSlug>/<sourceId>/<relationKey>` and to nothing else.
|
|
10
|
+
*/
|
|
11
|
+
export interface JunctionLink {
|
|
12
|
+
schema: string;
|
|
13
|
+
/** The junction table itself, e.g. `posts_tags`. */
|
|
14
|
+
table: string;
|
|
15
|
+
/** The collection whose relation this is, e.g. `posts`. */
|
|
16
|
+
parentCollection: CollectionConfig;
|
|
17
|
+
/** The relation's key — the path segment a child list is addressed by. */
|
|
18
|
+
relationKey: string;
|
|
19
|
+
/** Junction column holding the parent's id. */
|
|
20
|
+
sourceColumn: string;
|
|
21
|
+
/** Junction column holding the target's id. */
|
|
22
|
+
targetColumn: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Every junction table reachable from a registered collection, once per
|
|
26
|
+
* relation that uses it.
|
|
27
|
+
*
|
|
28
|
+
* A junction is listed once per *direction* when both sides declare it, because
|
|
29
|
+
* each direction addresses a different child list: `posts/1/tags` and
|
|
30
|
+
* `tags/t/posts` both change when one link is written.
|
|
31
|
+
*/
|
|
32
|
+
export declare function collectJunctionLinks(registry: PostgresCollectionRegistry): JunctionLink[];
|
|
33
|
+
/**
|
|
34
|
+
* Index {@link collectJunctionLinks} by table, under both the qualified and the
|
|
35
|
+
* bare name — a change event carries whatever the trigger reports, and a
|
|
36
|
+
* collection need not declare a schema.
|
|
37
|
+
*/
|
|
38
|
+
export declare function buildJunctionLinkMap(registry: PostgresCollectionRegistry): Map<string, JunctionLink[]>;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { CollectionConfig, ResolvedRelation } from "@rebasepro/types";
|
|
2
|
+
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
3
|
+
/**
|
|
4
|
+
* The last hop of a nested collection path, e.g. `authors/1/posts`.
|
|
5
|
+
*
|
|
6
|
+
* The walk that produces this was written out four separate times — in
|
|
7
|
+
* `FetchService.fetchCollectionFromPath`, `FetchService.countEntitiesFromPath`,
|
|
8
|
+
* `PersistService.save` and `CollectionRegistry.getCollectionByPath` — and had
|
|
9
|
+
* drifted, so the read path and the write path did not agree on which relation
|
|
10
|
+
* a path named. It lives here once now.
|
|
11
|
+
*/
|
|
12
|
+
export interface NestedPathHop {
|
|
13
|
+
/** The collection the final relation is declared on (e.g. `authors`). */
|
|
14
|
+
parentCollection: CollectionConfig;
|
|
15
|
+
/** The parent's id as it appeared in the path, unparsed. */
|
|
16
|
+
parentId: string;
|
|
17
|
+
/** The path segment that named the relation (e.g. `posts`). */
|
|
18
|
+
relationKey: string;
|
|
19
|
+
relation: ResolvedRelation;
|
|
20
|
+
/** `relation.target()`, resolved once. */
|
|
21
|
+
targetCollection: CollectionConfig;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* True when `path` addresses rows through a relation rather than a root
|
|
25
|
+
* collection.
|
|
26
|
+
*
|
|
27
|
+
* Any separator at all counts — a root collection slug never contains one — so
|
|
28
|
+
* a malformed path like `collection/id` is a *broken* nested path and gets
|
|
29
|
+
* reported as one by {@link resolveNestedPath}, rather than being looked up as
|
|
30
|
+
* a root collection whose slug happens to contain a slash.
|
|
31
|
+
*/
|
|
32
|
+
export declare function isNestedPath(path: string): boolean;
|
|
33
|
+
export declare function splitPathSegments(path: string): string[];
|
|
34
|
+
/**
|
|
35
|
+
* Walk a nested collection path down to the relation it ends in.
|
|
36
|
+
*
|
|
37
|
+
* Returns `undefined` for a plain root-collection path so callers can keep the
|
|
38
|
+
* root case on its existing code path. Throws when the path is malformed, or
|
|
39
|
+
* when a segment names a relation that does not exist — the same errors the
|
|
40
|
+
* individual walks used to raise, with the available names attached.
|
|
41
|
+
*/
|
|
42
|
+
export declare function resolveNestedPath(path: string, registry: PostgresCollectionRegistry): NestedPathHop | undefined;
|
|
43
|
+
/**
|
|
44
|
+
* A relation reached through a junction table — many-to-many, or a multi-hop
|
|
45
|
+
* `joinPath`. The target row is shared with other parents, so writing "through"
|
|
46
|
+
* such a path addresses the *link*, not the row.
|
|
47
|
+
*/
|
|
48
|
+
export declare function isJunctionBackedRelation(relation: ResolvedRelation): boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Reject a nested write whose final segment is a to-one relation.
|
|
51
|
+
*
|
|
52
|
+
* There is no column on the target row that records a to-one parent — the
|
|
53
|
+
* foreign key lives on the *parent* table. The write path used to fall through
|
|
54
|
+
* to `relation.localKey` here and stamp the parent's own FK column onto the
|
|
55
|
+
* target row, which either raised an opaque "column does not exist" or, when a
|
|
56
|
+
* column of that name happened to exist on the target, silently wrote the wrong
|
|
57
|
+
* one.
|
|
58
|
+
*/
|
|
59
|
+
export declare function assertWritableThrough(hop: NestedPathHop, path: string): void;
|
|
@@ -95,6 +95,8 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
|
|
|
95
95
|
private cdcListener?;
|
|
96
96
|
/** Whether database-level CDC is the active cross-instance change source. */
|
|
97
97
|
private cdcActive;
|
|
98
|
+
/** Junction table → the child lists its rows belong to, built when CDC starts. */
|
|
99
|
+
private junctionLinkMap?;
|
|
98
100
|
/** Reverse lookup: `schema.table` (and bare `table`) → collection, built when CDC starts. */
|
|
99
101
|
private cdcTableMap?;
|
|
100
102
|
/**
|
|
@@ -437,6 +439,23 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
|
|
|
437
439
|
* subscriber, never per publisher.
|
|
438
440
|
*/
|
|
439
441
|
private handleCdcEvent;
|
|
442
|
+
/**
|
|
443
|
+
* Deliver a change on a many-to-many junction table as a change to the child
|
|
444
|
+
* lists it belongs to.
|
|
445
|
+
*
|
|
446
|
+
* Linking a tag to a post writes only `posts_tags`. That table backs no
|
|
447
|
+
* collection, so change capture dropped the event as unmapped and the
|
|
448
|
+
* subscribers of `posts/1/tags` never heard about it — every other write in
|
|
449
|
+
* the system was realtime, and this one silently was not. The junction row
|
|
450
|
+
* carries both ids, so it names its own paths exactly.
|
|
451
|
+
*
|
|
452
|
+
* Notifies the nested path rather than either endpoint collection, because
|
|
453
|
+
* invalidation walks *parent* paths and never child ones: telling `tags` it
|
|
454
|
+
* changed would not reach a subscription on `posts/1/tags`.
|
|
455
|
+
*
|
|
456
|
+
* Returns whether the table was recognised as a junction.
|
|
457
|
+
*/
|
|
458
|
+
private handleJunctionCdcEvent;
|
|
440
459
|
/** Compute the canonical (possibly composite) id string from a captured row. */
|
|
441
460
|
private extractIdFromCdcRow;
|
|
442
461
|
private dedupKey;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CollectionConfig,
|
|
1
|
+
import { CollectionConfig, ResolvedRelation } from "@rebasepro/types";
|
|
2
2
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
3
3
|
/**
|
|
4
4
|
* Turning a drizzle result into a row we serve.
|
|
@@ -26,7 +26,7 @@ export type RelationStyle = "ref" | "inline";
|
|
|
26
26
|
* the query has to nest one level deeper for a junction, and the row walk has
|
|
27
27
|
* to unwrap that same level back out.
|
|
28
28
|
*/
|
|
29
|
-
export declare function isJunctionRelation(relation:
|
|
29
|
+
export declare function isJunctionRelation(relation: ResolvedRelation): boolean;
|
|
30
30
|
/**
|
|
31
31
|
* The address a relation ref points at.
|
|
32
32
|
*
|