pockethost 1.0.0-rc.1

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.
Files changed (44) hide show
  1. package/LICENSE.md +16 -0
  2. package/dist/index.mjs +3156 -0
  3. package/dist/instance-app/migrations/1699198734_pockethost_defaults.js +39 -0
  4. package/dist/instance-app/pb_hooks/_ph_admin_sync.pb.js +71 -0
  5. package/dist/instance-app/pb_hooks/_ph_lib.js +19 -0
  6. package/dist/instance-app/tsconfig.json +8 -0
  7. package/dist/instance-app/types/goja.d.ts +58 -0
  8. package/dist/instance-app/types/lib.d.ts +5 -0
  9. package/dist/instance-app/types/types.d.ts +19573 -0
  10. package/dist/mothership-app/migrations/1705062576_collections_snapshot.js +1863 -0
  11. package/dist/mothership-app/migrations/1705083826_updated_stats.js +721 -0
  12. package/dist/mothership-app/migrations/1706060446_updated_instances.js +44 -0
  13. package/dist/mothership-app/pb_hooks/src/instances-create.pb.js +65 -0
  14. package/dist/mothership-app/pb_hooks/src/instances-delete.pb.js +69 -0
  15. package/dist/mothership-app/pb_hooks/src/instances-migrate-versions.pb.js +33 -0
  16. package/dist/mothership-app/pb_hooks/src/instances-notify-discord-after-create.pb.js +31 -0
  17. package/dist/mothership-app/pb_hooks/src/instances-notify-on-maintenance-mode.pb.js +52 -0
  18. package/dist/mothership-app/pb_hooks/src/instances-reset-idle.pb.js +7 -0
  19. package/dist/mothership-app/pb_hooks/src/instances-update.pb.js +121 -0
  20. package/dist/mothership-app/pb_hooks/src/instances-validate-before-create.pb.js +16 -0
  21. package/dist/mothership-app/pb_hooks/src/instances-validate-before-update.pb.js +43 -0
  22. package/dist/mothership-app/pb_hooks/src/lib.js +171 -0
  23. package/dist/mothership-app/pb_hooks/src/ls.pb.js +159 -0
  24. package/dist/mothership-app/pb_hooks/src/mail.pb.js +49 -0
  25. package/dist/mothership-app/pb_hooks/src/notifications.pb.js +62 -0
  26. package/dist/mothership-app/pb_hooks/src/random-words.js +2085 -0
  27. package/dist/mothership-app/pb_hooks/src/signup.pb.js +214 -0
  28. package/dist/mothership-app/pb_hooks/src/sns.pb.js +133 -0
  29. package/dist/mothership-app/pb_hooks/src/unsubscribe.pb.js +33 -0
  30. package/dist/mothership-app/pb_hooks/src/user-token.pb.js +33 -0
  31. package/dist/mothership-app/pb_hooks/src/user-welcome-msg.pb.js +32 -0
  32. package/dist/mothership-app/pb_hooks/src/versions-route.pb.js +12 -0
  33. package/dist/mothership-app/pb_hooks/src/versions.js +0 -0
  34. package/dist/mothership-app/pb_hooks/tsconfig.json +8 -0
  35. package/dist/mothership-app/pb_hooks/types/global.d.ts +1 -0
  36. package/dist/mothership-app/pb_hooks/types/goja.d.ts +58 -0
  37. package/dist/mothership-app/pb_hooks/types/lemon.d.ts +12 -0
  38. package/dist/mothership-app/pb_hooks/types/lib.d.ts +55 -0
  39. package/dist/mothership-app/pb_hooks/types/types.d.ts +19573 -0
  40. package/dist/mothership-app/ph_app/index.js +11 -0
  41. package/dist/mothership-app/ph_app/package-lock.json +657 -0
  42. package/dist/mothership-app/ph_app/package.json +14 -0
  43. package/package.json +144 -0
  44. package/readme.md +168 -0
@@ -0,0 +1,214 @@
1
+ /// <reference path="../types/types.d.ts" />
2
+
3
+ routerAdd(
4
+ 'GET',
5
+ '/api/signup',
6
+ (c) => {
7
+ /**
8
+ * @param {string} slug
9
+ * @returns
10
+ */
11
+ const isAvailable = (slug) => {
12
+ try {
13
+ const record = $app
14
+ .dao()
15
+ .findFirstRecordByData('instances', 'subdomain', slug)
16
+ return false
17
+ } catch {
18
+ return true
19
+ }
20
+ }
21
+
22
+ /**
23
+ * @param {string} fieldName
24
+ * @param {string} slug
25
+ * @param {string} description
26
+ * @param {StringKvLookup} [extra]
27
+ * @returns
28
+ */
29
+ const error = (fieldName, slug, description, extra) =>
30
+ new ApiError(500, description, {
31
+ [fieldName]: new ValidationError(slug, description),
32
+ ...extra,
33
+ })
34
+
35
+ const instanceName = (() => {
36
+ const name = c.queryParam('name').trim()
37
+ if (name) {
38
+ if (name.match(/^[a-z][a-z0-9-]{2,39}$/) === null) {
39
+ throw error(
40
+ `instanceName`,
41
+ `invalid`,
42
+ `Instance name must begin with a letter, be between 3-40 characters, and can only contain a-z, 0-9, and hyphen (-).`,
43
+ )
44
+ }
45
+ if (isAvailable(name)) {
46
+ return name
47
+ }
48
+ throw error(
49
+ `instanceName`,
50
+ `exists`,
51
+ `Instance name ${name} is not available.`,
52
+ )
53
+ } else {
54
+ const random = require(`${__hooks}/random-words.js`)
55
+ let i = 0
56
+ while (true) {
57
+ i++
58
+ if (i > 100) {
59
+ return +new Date()
60
+ }
61
+ const slug = random.generate(2).join(`-`)
62
+ if (isAvailable(slug)) return slug
63
+ }
64
+ }
65
+ })()
66
+ return c.json(200, { instanceName })
67
+ } /* optional middlewares */,
68
+ )
69
+
70
+ /*
71
+ // HTTP 200
72
+ {
73
+ status: 'ok'
74
+ }
75
+
76
+ // HTTP 500
77
+ {
78
+ "code": 500,
79
+ "message": "That user account already exists. Try a password reset.",
80
+ "data": {
81
+ "email": {
82
+ "code": "exists",
83
+ "message": "That user account already exists. Try a password reset."
84
+ }
85
+ }
86
+ }
87
+
88
+ {
89
+ "code": 500,
90
+ "message": "Instance name was taken, sorry about that. Try another.",
91
+ "data": {
92
+ "instanceName": {
93
+ "code": "exists",
94
+ "message": "Instance name was taken, sorry about that. Try another."
95
+ }
96
+ }
97
+ }
98
+ */
99
+
100
+ // https://pocketbase.io/docs/js-routing/#sending-request-to-custom-routes-using-the-sdks
101
+ routerAdd(
102
+ 'POST',
103
+ '/api/signup',
104
+ (c) => {
105
+ const dao = $app.dao()
106
+ const parsed = (() => {
107
+ const rawBody = readerToString(c.request().body)
108
+ try {
109
+ const parsed = JSON.parse(rawBody)
110
+ return parsed
111
+ } catch (e) {
112
+ throw new BadRequestError(
113
+ `Error parsing payload. You call this JSON? ${rawBody}`,
114
+ e,
115
+ )
116
+ }
117
+ })()
118
+ const email = parsed.email?.trim()
119
+ const password = parsed.password?.trim()
120
+ const desiredInstanceName = parsed.instanceName?.trim()
121
+
122
+ /**
123
+ * @param {string} fieldName
124
+ * @param {string} slug
125
+ * @param {string} description
126
+ * @param {StringKvLookup} [extra]
127
+ * @returns
128
+ */
129
+ const error = (fieldName, slug, description, extra) =>
130
+ new ApiError(500, description, {
131
+ [fieldName]: new ValidationError(slug, description),
132
+ ...extra,
133
+ })
134
+
135
+ if (!email) {
136
+ throw error(`email`, 'required', 'Email is required')
137
+ }
138
+
139
+ if (!password) {
140
+ throw error(`password`, `required`, 'Password is required')
141
+ }
142
+
143
+ if (!desiredInstanceName) {
144
+ throw error(`instanceName`, `required`, `Instance name is required`)
145
+ }
146
+
147
+ const userExists = (() => {
148
+ try {
149
+ const record = dao.findFirstRecordByData('users', 'email', email)
150
+ return true
151
+ } catch {
152
+ return false
153
+ }
154
+ })()
155
+
156
+ if (userExists) {
157
+ throw error(
158
+ `email`,
159
+ `exists`,
160
+ `That user account already exists. Try a password reset.`,
161
+ )
162
+ }
163
+
164
+ dao.runInTransaction((txDao) => {
165
+ const usersCollection = dao.findCollectionByNameOrId('users')
166
+ const instanceCollection = $app
167
+ .dao()
168
+ .findCollectionByNameOrId('instances')
169
+
170
+ const user = new Record(usersCollection)
171
+ try {
172
+ const username = $app
173
+ .dao()
174
+ .suggestUniqueAuthRecordUsername(
175
+ 'users',
176
+ 'user' + $security.randomStringWithAlphabet(5, '123456789'),
177
+ )
178
+ user.set('username', username)
179
+ user.set('email', email)
180
+ user.set('subscription', 'free')
181
+ user.set('notifyMaintenanceMode', true)
182
+ user.setPassword(password)
183
+ txDao.saveRecord(user)
184
+ } catch (e) {
185
+ throw error(`email`, `fail`, `Could not create user: ${e}`)
186
+ }
187
+
188
+ try {
189
+ const instance = new Record(instanceCollection)
190
+ instance.set('subdomain', desiredInstanceName)
191
+ instance.set('uid', user.get('id'))
192
+ instance.set('status', 'idle')
193
+ instance.set('notifyMaintenanceMode', true)
194
+ instance.set('syncAdmin', true)
195
+ const { versions } = require(`${__hooks}/versions.js`)
196
+ instance.set('version', versions[0])
197
+ txDao.saveRecord(instance)
198
+ } catch (e) {
199
+ if (`${e}`.match(/ UNIQUE /)) {
200
+ throw error(
201
+ `instanceName`,
202
+ `exists`,
203
+ `Instance name was taken, sorry about that. Try another.`,
204
+ )
205
+ }
206
+ throw error(`instanceName`, `fail`, `Could not create instance: ${e}`)
207
+ }
208
+
209
+ $mails.sendRecordVerification($app, user)
210
+ })
211
+
212
+ return c.json(200, { status: 'ok' })
213
+ } /* optional middlewares */,
214
+ )
@@ -0,0 +1,133 @@
1
+ /// <reference path="../types/types.d.ts" />
2
+
3
+ routerAdd('POST', '/api/sns', (c) => {
4
+ const dao = $app.dao()
5
+ const { mkLog, audit } = /** @type {Lib} */ (require(`${__hooks}/lib.js`))
6
+ const log = mkLog(`sns`)
7
+
8
+ const processBounce = (emailAddress) => {
9
+ log(`Processing ${emailAddress}`)
10
+ const extra = /** @type {{ email: string; user: string }} */ ({
11
+ email: emailAddress,
12
+ })
13
+ try {
14
+ const user = dao.findFirstRecordByData('users', 'email', emailAddress)
15
+ log(`user is`, user)
16
+ extra.user = user.getId()
17
+ user.setVerified(false)
18
+ dao.saveRecord(user)
19
+ audit('PBOUNCE', `User ${emailAddress} has been disabled`, {
20
+ dao,
21
+ extra,
22
+ log,
23
+ })
24
+ } catch (e) {
25
+ audit('PBOUNCE_ERR', `${e}`, { dao, log, extra })
26
+ log(`After audit`)
27
+ }
28
+ }
29
+
30
+ ;[].forEach(processBounce)
31
+
32
+ const raw = readerToString(c.request().body)
33
+ const data = JSON.parse(raw)
34
+ log(JSON.stringify(data, null, 2))
35
+
36
+ const { Type } = data
37
+
38
+ switch (Type) {
39
+ case `SubscriptionConfirmation`:
40
+ const url = data.SubscribeURL
41
+ log(url)
42
+ $http.send({ url })
43
+ return c.json(200, { status: 'ok' })
44
+
45
+ case `Notification`:
46
+ const msg = JSON.parse(data.Message)
47
+ log(msg)
48
+ const { notificationType } = msg
49
+ switch (notificationType) {
50
+ case `Bounce`: {
51
+ log(`Message is a bounce`)
52
+ const { bounce } = msg
53
+ const { bounceType } = bounce
54
+ switch (bounceType) {
55
+ case `Permanent`:
56
+ log(`Message is a permanent bounce`)
57
+ const { bouncedRecipients } = bounce
58
+ bouncedRecipients.forEach((recipient) => {
59
+ const { emailAddress } = recipient
60
+ processBounce(emailAddress)
61
+ })
62
+ break
63
+ default:
64
+ audit('SNS_ERR', `Unrecognized bounce type ${bounceType}`, {
65
+ dao,
66
+ log,
67
+ extra: { raw_payload: raw },
68
+ })
69
+ }
70
+ break
71
+ }
72
+ case `Complaint`:
73
+ {
74
+ log(`Message is a Complaint`, msg)
75
+ const { complaint } = msg
76
+ const { complainedRecipients } = complaint
77
+ complainedRecipients.forEach((recipient) => {
78
+ const { emailAddress } = recipient
79
+ log(`Processing ${emailAddress}`)
80
+ try {
81
+ const user = $app
82
+ .dao()
83
+ .findFirstRecordByData('users', 'email', emailAddress)
84
+ log(`user is`, user)
85
+ user.set(`unsubscribe`, true)
86
+ dao.saveRecord(user)
87
+ audit(
88
+ 'COMPLAINT',
89
+ `User ${emailAddress} has been unsubscribed`,
90
+ {
91
+ log,
92
+ dao,
93
+ extra: { email: emailAddress, user: user.getId() },
94
+ },
95
+ )
96
+ } catch (e) {
97
+ audit(
98
+ 'COMPLAINT_ERR',
99
+ `${emailAddress} is not in the system.`,
100
+ {
101
+ log,
102
+ dao,
103
+ extra: {
104
+ email: emailAddress,
105
+ },
106
+ },
107
+ )
108
+ }
109
+ })
110
+ }
111
+ break
112
+ default:
113
+ audit(
114
+ 'SNS_ERR',
115
+ `Unrecognized notification type ${notificationType}`,
116
+ {
117
+ log,
118
+ dao,
119
+ extra: { raw_payload: raw },
120
+ },
121
+ )
122
+ }
123
+ break
124
+ default:
125
+ audit(`SNS_ERR`, `Message ${Type} not handled`, {
126
+ log,
127
+ dao,
128
+ extra: { raw_payload: raw },
129
+ })
130
+ }
131
+
132
+ return c.json(200, { status: 'ok' })
133
+ })
@@ -0,0 +1,33 @@
1
+ /// <reference path="../types/types.d.ts" />
2
+
3
+ routerAdd('GET', '/api/unsubscribe', (c) => {
4
+ const dao = $app.dao()
5
+ const { mkLog, audit } = /** @type {Lib} */ (require(`${__hooks}/lib.js`))
6
+ const log = mkLog(`unsubscribe`)
7
+
8
+ const id = c.queryParam('e')
9
+
10
+ try {
11
+ const record = dao.findRecordById('users', id)
12
+ record.set(`unsubscribe`, true)
13
+ dao.saveRecord(record)
14
+
15
+ const email = record.getString('email')
16
+ audit('UNSUBSCRIBE', '', { dao, log, extra: { email, user: id } })
17
+
18
+ $app.newMailClient().send(
19
+ new MailerMessage({
20
+ from: {
21
+ address: $app.settings().meta.senderAddress,
22
+ name: $app.settings().meta.senderName,
23
+ },
24
+ to: [{ address: `ben@benallfree.com` }],
25
+ subject: `UNSUBSCRIBE ${email}`,
26
+ }),
27
+ )
28
+ return c.html(200, `<p>${email} has been unsubscribed.`)
29
+ } catch (e) {
30
+ audit('UNSUBSCRIBE_ERR', `User ${id} not found`, { dao, log })
31
+ return c.html(200, `<p>Looks like you're already unsubscribed.`)
32
+ }
33
+ })
@@ -0,0 +1,33 @@
1
+ /// <reference path="../types/types.d.ts" />
2
+
3
+ /*
4
+ {
5
+ "id": "user-id"
6
+ }
7
+ */
8
+ routerAdd(
9
+ 'GET',
10
+ '/api/userToken/:id',
11
+ (c) => {
12
+ const dao = $app.dao()
13
+ const { mkLog } = /** @type {Lib} */ (require(`${__hooks}/lib.js`))
14
+ const log = mkLog(`user-token`)
15
+
16
+ const id = c.pathParam('id')
17
+
18
+ log({ id })
19
+
20
+ if (!id) {
21
+ throw new BadRequestError(`User ID is required.`)
22
+ }
23
+
24
+ const rec = dao.findRecordById('users', id)
25
+ const tokenKey = rec.getString('tokenKey')
26
+ const passwordHash = rec.getString('passwordHash')
27
+ const email = rec.getString(`email`)
28
+ log({ email, passwordHash, tokenKey })
29
+
30
+ return c.json(200, { email, passwordHash, tokenKey })
31
+ },
32
+ $apis.requireAdminAuth(),
33
+ )
@@ -0,0 +1,32 @@
1
+ /// <reference path="../types/types.d.ts" />
2
+
3
+ onModelBeforeUpdate((e) => {
4
+ const dao = e.dao || $app.dao()
5
+ const newModel = /** @type {models.Record} */ (e.model)
6
+ const oldModel = newModel.originalCopy()
7
+
8
+ const { audit, mkLog, enqueueNotification } = /** @type {Lib} */ (
9
+ require(`${__hooks}/lib.js`)
10
+ )
11
+
12
+ const log = mkLog(`user-welcome-msg`)
13
+ try {
14
+ log({ newModel, oldModel })
15
+
16
+ // Bail out if we aren't verified
17
+ const isVerified = newModel.getBool('verified')
18
+ if (!isVerified) return
19
+
20
+ // Bail out if the verified mode flag has not changed
21
+ if (isVerified === oldModel.getBool(`verified`)) return
22
+
23
+ log(`user just became verified`)
24
+ const uid = newModel.getId()
25
+
26
+ enqueueNotification(`email`, `welcome`, uid, { log, dao })
27
+ newModel.set(`welcome`, new DateTime())
28
+ } catch (e) {
29
+ audit(`ERROR`, `${e}`, { log, dao, extra: { user: newModel.getId() } })
30
+ throw e
31
+ }
32
+ }, 'users')
@@ -0,0 +1,12 @@
1
+ /// <reference path="../types/types.d.ts" />
2
+
3
+ /** Return a list of available PocketBase versions */
4
+ routerAdd(
5
+ 'GET',
6
+ '/api/versions',
7
+ (c) => {
8
+ const { versions } = require(`${__hooks}/versions.js`)
9
+
10
+ return c.json(200, { versions })
11
+ } /* optional middlewares */,
12
+ )
File without changes
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "noImplicitAny": false,
5
+ "strictNullChecks": false
6
+ },
7
+ "include": ["src", "types"]
8
+ }
@@ -0,0 +1 @@
1
+ function seedrandom(seed: number): void
@@ -0,0 +1,58 @@
1
+ interface Require {
2
+ (id: string): any
3
+ }
4
+ declare var require: Require
5
+
6
+ /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console) */
7
+ interface Console {
8
+ /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/assert) */
9
+ assert(condition?: boolean, ...data: any[]): void
10
+ /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/clear) */
11
+ clear(): void
12
+ /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/count) */
13
+ count(label?: string): void
14
+ /**
15
+ * [MDN
16
+ * Reference](https://developer.mozilla.org/docs/Web/API/console/countReset)
17
+ */
18
+ countReset(label?: string): void
19
+ /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/debug) */
20
+ debug(...data: any[]): void
21
+ /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dir) */
22
+ dir(item?: any, options?: any): void
23
+ /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dirxml) */
24
+ dirxml(...data: any[]): void
25
+ /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/error) */
26
+ error(...data: any[]): void
27
+ /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/group) */
28
+ group(...data: any[]): void
29
+ /**
30
+ * [MDN
31
+ * Reference](https://developer.mozilla.org/docs/Web/API/console/groupCollapsed)
32
+ */
33
+ groupCollapsed(...data: any[]): void
34
+ /**
35
+ * [MDN
36
+ * Reference](https://developer.mozilla.org/docs/Web/API/console/groupEnd)
37
+ */
38
+ groupEnd(): void
39
+ /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/info) */
40
+ info(...data: any[]): void
41
+ /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log) */
42
+ log(...data: any[]): void
43
+ /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/table) */
44
+ table(tabularData?: any, properties?: string[]): void
45
+ /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/time) */
46
+ time(label?: string): void
47
+ /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeEnd) */
48
+ timeEnd(label?: string): void
49
+ /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeLog) */
50
+ timeLog(label?: string, ...data: any[]): void
51
+ timeStamp(label?: string): void
52
+ /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/trace) */
53
+ trace(...data: any[]): void
54
+ /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/warn) */
55
+ warn(...data: any[]): void
56
+ }
57
+
58
+ declare var console: Console
@@ -0,0 +1,12 @@
1
+ interface WebHook {
2
+ meta: { custom_data: { user_id: string } }
3
+ data: {
4
+ type: 'orders' | string
5
+ attributes: {
6
+ order_number: number
7
+ first_order_item: { product_name: string; product_id: number }
8
+ status: 'active' | 'paid' | string
9
+ user_email: string
10
+ }
11
+ }
12
+ }
@@ -0,0 +1,55 @@
1
+ type Logger = (...args: any[]) => void
2
+
3
+ type StringKvLookup = { [_: string]: string }
4
+
5
+ type AuditEvents =
6
+ | 'ERROR'
7
+ | 'NOTIFICATION_ERR'
8
+ | 'LS'
9
+ | 'LS_ERR'
10
+ | 'PBOUNCE'
11
+ | 'PBOUNCE_ERR'
12
+ | 'SNS_ERR'
13
+ | 'COMPLAINT'
14
+ | 'COMPLAINT_ERR'
15
+ | 'UNSUBSCRIBE'
16
+ | 'UNSUBSCRIBE_ERR'
17
+
18
+ interface Lib {
19
+ mkLog: (namespace: string) => Logger
20
+ processNotification: (
21
+ notificationRec: models.Record,
22
+ context: { log: Logger; test?: boolean; dao: daos.Dao },
23
+ ) => void
24
+ enqueueNotification: (
25
+ channel: 'email' | 'lemonbot',
26
+ template:
27
+ | 'maintenance-mode'
28
+ | 'lemon_order_email'
29
+ | 'lemon_order_discord'
30
+ | 'welcome',
31
+ user_id: string,
32
+ context: {
33
+ message_template_vars?: { [_: string]: string }
34
+ dao: daos.Dao
35
+ log: Logger
36
+ },
37
+ ) => void
38
+
39
+ audit: (
40
+ event: AuditEvents,
41
+ note: string,
42
+ context: {
43
+ log: Logger
44
+
45
+ dao: daos.Dao
46
+ extra?: Partial<{
47
+ notification: string
48
+ email: string
49
+ user: string
50
+ raw_payload: string
51
+ }>
52
+ },
53
+ ) => void
54
+ removeEmptyKeys: <T>(obj: T) => T
55
+ }